Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
46 / 46
100.00% covered (success)
100.00%
8 / 8
CRAP
100.00% covered (success)
100.00%
1 / 1
SodiumEncrypter
100.00% covered (success)
100.00%
46 / 46
100.00% covered (success)
100.00%
8 / 8
25
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 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%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 isAvailable
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isValid
100.00% covered (success)
100.00%
5 / 5
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%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 decrypt
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
10
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 sodium 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 SodiumEncrypter extends AbstractEncrypter
28{
29
30    /**
31     * Cipher constant
32     */
33    const CIPHER = 'xchacha20-poly1305';
34
35    /**
36     * Constructor
37     *
38     * Instantiate the SodiumEncrypter object
39     *
40     * @param  string $key
41     * @param  bool   $raw
42     * @throws Exception
43     */
44    public function __construct(string $key, bool $raw = true)
45    {
46        parent::__construct($key, static::CIPHER, $raw);
47    }
48
49    /**
50     * Create sodium encrypter object
51     *
52     * @param  bool $raw
53     * @return static
54     */
55    public static function create(bool $raw = true): static
56    {
57        return new static(static::generateKey($raw), $raw);
58    }
59
60    /**
61     * Load sodium encrypter object from $_ENV
62     *
63     * Defaults to treating APP_KEY/APP_PREVIOUS_KEYS as base64-encoded strings,
64     * since that's the standard way to store binary key material in a .env file.
65     *
66     * @param  bool $raw
67     * @throws Exception
68     * @return static
69     */
70    public static function load(bool $raw = false): static
71    {
72        $key          = null;
73        $previousKeys = null;
74
75        if (!empty($_ENV['APP_KEY'])) {
76            $key = trim($_ENV['APP_KEY']);
77        }
78        if (!empty($_ENV['APP_PREVIOUS_KEYS'])) {
79            $previousKeys = array_map('trim', explode(',', $_ENV['APP_PREVIOUS_KEYS']));
80        }
81
82        if (empty($key)) {
83            throw new Exception('Error: The encryption properties could not be loaded.');
84        }
85
86        $encrypter = new static($key, $raw);
87
88        if (!empty($previousKeys)) {
89            $encrypter->setPreviousKeys($previousKeys, $raw);
90        }
91
92        return $encrypter;
93    }
94
95    /**
96     * Determine if the cipher is available
97     *
98     * @param  string $cipher
99     * @return bool
100     */
101    public static function isAvailable(string $cipher): bool
102    {
103        return (strtolower($cipher) === static::CIPHER) && extension_loaded('sodium');
104    }
105
106    /**
107     * Determine if the key and cipher combination is valid
108     *
109     * @param  string $key
110     * @param  string $cipher
111     * @param  bool   $raw
112     * @return bool
113     */
114    public static function isValid(string $key, string $cipher, bool $raw = true): bool
115    {
116        if (!static::isAvailable($cipher)) {
117            return false;
118        }
119        if (!$raw) {
120            $key = base64_decode($key);
121        }
122        return (mb_strlen($key, '8bit') === SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES);
123    }
124
125    /**
126     * Generate encryption key
127     *
128     * @param  bool $raw
129     * @return string
130     */
131    public static function generateKey(bool $raw = true): string
132    {
133        $key = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES);
134        return ($raw) ? $key : base64_encode($key);
135    }
136
137    /**
138     * Encrypt value
139     *
140     * @param  string $value
141     * @return string
142     */
143    public function encrypt(#[\SensitiveParameter] string $value): string
144    {
145        // The 24-byte XChaCha20 nonce is large enough that a fresh random value
146        // per message is safe at any realistic volume (unlike GCM's 96-bit nonce,
147        // which has a birthday-bound collision risk at very high encryption
148        // volumes under a single key).
149        $nonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
150        $value = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($value, '', $nonce, $this->key);
151
152        $json = json_encode([
153            'iv'    => base64_encode($nonce),
154            'value' => base64_encode($value),
155        ], JSON_UNESCAPED_SLASHES);
156
157        return base64_encode($json);
158    }
159
160    /**
161     * Decrypt value
162     *
163     * @param  string $payload
164     * @throws Exception
165     * @return string
166     */
167    public function decrypt(string $payload): string
168    {
169        $payload = json_decode(base64_decode($payload), true);
170
171        if (!is_array($payload) || (!isset($payload['iv']) || !isset($payload['value']))) {
172            throw new Exception('Error: The payload is not valid data.');
173        }
174
175        // Validate that iv and value are strings (prevent TypeError from base64_decode)
176        if (!is_string($payload['iv']) || !is_string($payload['value'])) {
177            throw new Exception('Error: The payload is not valid data.');
178        }
179
180        $nonce     = base64_decode($payload['iv']);
181        $value     = base64_decode($payload['value']);
182        $decrypted = false;
183
184        foreach ($this->getAllKeys() as $key) {
185            try {
186                $decrypted = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($value, '', $nonce, $key);
187            } catch (\SodiumException $e) {
188                // Wrong nonce length or other sodium error - treat as decryption failure
189                $decrypted = false;
190            }
191
192            if ($decrypted !== false) {
193                break;
194            }
195        }
196
197        if ($decrypted === false) {
198            throw new Exception('Error: Unable to decrypt the data.');
199        }
200
201        return $decrypted;
202    }
203
204}