Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
95 / 95
100.00% covered (success)
100.00%
14 / 14
CRAP
100.00% covered (success)
100.00%
1 / 1
Encoded
100.00% covered (success)
100.00%
95 / 95
100.00% covered (success)
100.00%
14 / 14
78
100.00% covered (success)
100.00%
1 / 1
 setColumns
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 toArray
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 encodeValue
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
23
 decodeValue
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
17
 verify
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 needsRehash
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 rehash
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 encode
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 decode
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 isEncodedColumn
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
5
 loadEncryptionProperties
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
6
 getRawValue
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __set
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 __get
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
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\Db\Record;
16
17use Pop\Crypt\Hashing\Hasher;
18use Pop\Crypt\Encryption\Encrypter;
19
20/**
21 * Encoded record class
22 *
23 * @category   Pop
24 * @package    Pop\Db
25 * @author     Nick Sagona, III <nick@popphp.org>
26 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
27 * @license    https://www.popphp.org/license     New BSD License
28 * @version    7.0.0
29 */
30class Encoded extends \Pop\Db\Record
31{
32
33    /**
34     * JSON-encoded fields
35     * @var array
36     */
37    protected array $jsonFields = [];
38
39    /**
40     * PHP-serialized fields
41     * @var array
42     */
43    protected array $phpFields = [];
44
45    /**
46     * Base64-encoded fields
47     * @var array
48     */
49    protected array $base64Fields = [];
50
51    /**
52     * Password-hashed fields
53     * @var array
54     */
55    protected array $hashFields = [];
56
57    /**
58     * Encrypted fields
59     * @var array
60     */
61    protected array $encryptedFields = [];
62
63    /**
64     * Hash algorithm
65     * @var string
66     */
67    protected string $hashAlgorithm = PASSWORD_BCRYPT;
68
69    /**
70     * Hash options
71     * @var array
72     */
73    protected array $hashOptions = [];
74
75    /**
76     * Encryption cipher method
77     * @var ?string
78     */
79    protected ?string $cipherMethod = null;
80
81    /**
82     * Encryption key
83     * @var ?string
84     */
85    protected ?string $key = null;
86
87    /**
88     * Encryption previous keys
89     * @var array
90     */
91    protected array $previousKeys = [];
92
93    /**
94     * Whether the last verified hash needs to be rehashed
95     * @var bool
96     */
97    protected bool $needsRehash = false;
98
99    /**
100     * Set all the table column values at once
101     *
102     * @param  mixed  $columns
103     * @throws Exception
104     * @return Encoded
105     */
106    public function setColumns(mixed $columns = null): Encoded
107    {
108        if ($columns !== null) {
109            parent::setColumns($this->encode($this->toColumnsArray($columns)));
110        }
111
112        return $this;
113    }
114
115    /**
116     * Get column values as array
117     *
118     * @throws Exception
119     * @return array
120     */
121    public function toArray(): array
122    {
123        $result = parent::toArray();
124
125        foreach ($result as $key => $value) {
126            if (($this->isEncodedColumn($key)) && ($value !== null)) {
127                $result[$key] = $this->decodeValue($key, $value);
128            }
129        }
130
131        return $result;
132    }
133
134    /**
135     * Encode value
136     *
137     * @param  string $key
138     * @param  mixed  $value
139     * @throws Exception
140     * @return string
141     */
142    public function encodeValue(string $key, mixed $value): string
143    {
144        if (in_array($key, $this->jsonFields)) {
145            if (!((is_string($value) && (json_decode($value) !== false)) && (json_last_error() == JSON_ERROR_NONE))) {
146                $value = json_encode($value);
147            }
148        } else if (in_array($key, $this->phpFields)) {
149            if (!(is_string($value) && (@unserialize($value) !== false))) {
150                $value = serialize($value);
151            }
152        } else if (in_array($key, $this->base64Fields)) {
153            if (!(is_string($value) && (base64_encode(base64_decode($value)) === $value))) {
154                $value = base64_encode($value);
155            }
156        } else if (in_array($key, $this->hashFields)) {
157            $hasher = Hasher::create($this->hashAlgorithm, $this->hashOptions);
158            $info   = $hasher->getInfo($value);
159            if (((int)$info['algo'] == 0) || (strtolower($info['algoName']) == 'unknown')) {
160                $value = $hasher->make($value);
161            }
162        } else if (in_array($key, $this->encryptedFields)) {
163            // Attempt to load encryption properties from $_ENV
164            if (empty($this->cipherMethod) || empty($this->key)) {
165                $this->loadEncryptionProperties();
166            }
167            if (empty($this->cipherMethod) || empty($this->key)) {
168                throw new Exception('Error: The encryption properties have not been set.');
169            }
170
171            $encrypter = new Encrypter($this->key, $this->cipherMethod, false);
172
173            // Load any previous encryption keys
174            if (!empty($this->previousKeys)) {
175                $encrypter->setPreviousKeys($this->previousKeys, false);
176            }
177
178            $decodedValue = $this->decodeValue($key, $value);
179            if (!(is_string($value) && ($decodedValue !== false) && ($decodedValue != $value))) {
180                $value = $encrypter->encrypt($value);
181            }
182        }
183
184        return $value;
185    }
186
187    /**
188     * Decode value
189     *
190     * @param  string $key
191     * @param  string  $value
192     * @throws Exception
193     * @return mixed
194     */
195    public function decodeValue(string $key, string $value): mixed
196    {
197        if (in_array($key, $this->jsonFields)) {
198            $jsonValue = @json_decode($value, true);
199            if (json_last_error() === JSON_ERROR_NONE) {
200                $value = $jsonValue;
201            }
202        } else if (in_array($key, $this->phpFields)) {
203            $phpValue = @unserialize($value);
204            if ($phpValue !== false) {
205                $value = $phpValue;
206            }
207        } else if (in_array($key, $this->base64Fields)) {
208            $base64Value = @base64_decode($value, true);
209            if ($base64Value !== false) {
210                $value = $base64Value;
211            }
212        } else if (in_array($key, $this->encryptedFields)) {
213            // Attempt to load encryption properties from $_ENV
214            if (empty($this->cipherMethod) || empty($this->key)) {
215                $this->loadEncryptionProperties();
216            }
217            if (empty($this->cipherMethod) || empty($this->key)) {
218                throw new Exception('Error: The encryption properties have not been set.');
219            }
220
221            $encrypter = new Encrypter($this->key, $this->cipherMethod, false);
222
223            // Load any previous encryption keys
224            if (!empty($this->previousKeys)) {
225                $encrypter->setPreviousKeys($this->previousKeys, false);
226            }
227
228            $base64Value = @base64_decode($value, true);
229            if ($base64Value !== false) {
230                // Test if payload is valid
231                $payload = json_decode(base64_decode($value), true);
232                if (is_array($payload) && isset($payload['iv']) && isset($payload['value'])) {
233                    $value = $encrypter->decrypt($value);
234                }
235            }
236        }
237
238        return $value;
239    }
240
241    /**
242     * Verify value against hash
243     *
244     * Also records whether the stored hash needs to be rehashed (outdated algorithm/cost),
245     * queryable via needsRehash() and actionable via rehash()
246     *
247     * @param  string $key
248     * @param  string $value
249     * @param  bool   $autoRehash
250     * @return bool
251     */
252    public function verify(string $key, string $value, bool $autoRehash = true): bool
253    {
254        $hasher = Hasher::create($this->hashAlgorithm, $this->hashOptions);
255        $hash   = $this->{$key};
256        $result = $hasher->verify($value, $hash);
257
258        $this->needsRehash = ($result && $hasher->requiresRehash($hash));
259
260        if (($autoRehash) && ($this->needsRehash)) {
261            $this->rehash($key, $value);
262        }
263
264        return $result;
265    }
266
267    /**
268     * Determine if the last verified hash needs to be rehashed
269     *
270     * @return bool
271     */
272    public function needsRehash(): bool
273    {
274        return $this->needsRehash;
275    }
276
277    /**
278     * Rehash and save the given field with a freshly-hashed value
279     *
280     * @param  string $key
281     * @param  string $value
282     * @return void
283     */
284    public function rehash(string $key, #[\SensitiveParameter] string $value): void
285    {
286        $this->{$key} = $value;
287        $this->save();
288        $this->needsRehash = false;
289    }
290
291    /**
292     * Scrub the column values and encode them
293     *
294     * @param  array $columns
295     * @throws Exception
296     * @return array
297     */
298    public function encode(array $columns): array
299    {
300        foreach ($columns as $key => $value) {
301            if (($value !== null) && ($this->isEncodedColumn($key))) {
302                $columns[$key] = $this->encodeValue($key, $value);
303            }
304        }
305
306        return $columns;
307    }
308
309    /**
310     * Scrub the column values and decode them
311     *
312     * @param  array $columns
313     * @throws Exception
314     * @return array
315     */
316    public function decode(array $columns): array
317    {
318        foreach ($columns as $key => $value) {
319            if (($this->isEncodedColumn($key)) && ($value !== null)) {
320                $columns[$key] = $this->decodeValue($key, $value);
321            }
322        }
323
324        return $columns;
325    }
326
327    /**
328     * Determine if column is an encoded column
329     *
330     * @param  string $key
331     * @return bool
332     */
333    public function isEncodedColumn(string $key): bool
334    {
335        return (in_array($key, $this->jsonFields) || in_array($key, $this->phpFields) ||
336            in_array($key, $this->base64Fields) || in_array($key, $this->hashFields) || in_array($key, $this->encryptedFields));
337    }
338
339    /**
340     * Attempt to load encryption properties from $_ENV vars
341     *
342     * @return void
343     */
344    public function loadEncryptionProperties(): void
345    {
346        if (empty($this->cipherMethod) && !empty($_ENV['APP_CIPHER_METHOD'])) {
347            $this->cipherMethod = trim($_ENV['APP_CIPHER_METHOD']);
348        }
349        if (empty($this->key) && !empty($_ENV['APP_KEY'])) {
350            $this->key = trim($_ENV['APP_KEY']);
351            if (!empty($_ENV['APP_PREVIOUS_KEYS'])) {
352                $this->previousKeys = array_map('trim', explode(',', $_ENV['APP_PREVIOUS_KEYS']));
353            }
354        }
355    }
356
357    /**
358     * Get raw un-encoded value
359     *
360     * @param  string $name
361     * @return mixed
362     */
363    public function getRawValue(string $name): mixed
364    {
365        return parent::__get($name);
366    }
367
368    /**
369     * Magic method to set the property to the value of $this->rowGateway[$name]
370     *
371     * @param  string $name
372     * @param  mixed  $value
373     * @throws Exception
374     * @return void
375     */
376    public function __set(string $name, mixed $value): void
377    {
378        if (($value !== null) && ($this->isEncodedColumn($name))) {
379            $value = $this->encodeValue($name, $value);
380        }
381        parent::__set($name, $value);
382    }
383
384    /**
385     * Magic method to return the value of $this->rowGateway[$name]
386     *
387     * @param  string $name
388     * @throws Exception
389     * @return mixed
390     */
391    public function __get(string $name): mixed
392    {
393        $value = parent::__get($name);
394
395        if (($this->isEncodedColumn($name)) && ($value !== null)) {
396            $value = $this->decodeValue($name, $value);
397        }
398
399        return $value;
400    }
401
402}