Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.91% covered (warning)
81.91%
77 / 94
75.00% covered (warning)
75.00%
6 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
AesGcmEncryptionService
81.72% covered (warning)
81.72%
76 / 93
75.00% covered (warning)
75.00%
6 / 8
45.36
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 encrypt
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
3
 decrypt
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
11
 isEncrypted
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 reEncrypt
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 resolveMasterKey
50.00% covered (danger)
50.00%
5 / 10
0.00% covered (danger)
0.00%
0 / 1
10.50
 resolvePreviousKeys
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 resolvePersistentKey
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Security\Encryption;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use InvalidArgumentException;
12use RuntimeException;
13
14/**
15 * Enterprise Authenticated Encryption Service (AES-256-GCM) with HKDF and Key Rotation.
16 *
17 * Implements authenticated encryption with associated data (AEAD) using AES-256-GCM cipher,
18 * HKDF-SHA256 key derivation (NIST SP 800-56C), multi-version key rings, and zero-downtime rotation.
19 *
20 * @package App\Core\Security\Encryption
21 */
22final readonly class AesGcmEncryptionService implements EncryptionServiceInterface
23{
24    /** @var string Cipher algorithm. */
25    private const CIPHER = 'aes-256-gcm';
26
27    /** @var string Legacy payload prefix format (v1 SHA-256 derivation). */
28    private const PREFIX_V1 = 'enc:v1:';
29
30    /** @var string Current payload prefix format (v2 HKDF-SHA256 derivation). */
31    private const PREFIX_V2 = 'enc:v2:';
32
33    /** @var string HKDF domain separation context info. */
34    private const HKDF_INFO = 'ammonly-aes-256-gcm-key';
35
36    /** @var int IV length in bytes for GCM. */
37    private const IV_LENGTH = 12;
38
39    /** @var int Tag length in bytes. */
40    private const TAG_LENGTH = 16;
41
42    /** @var list<string> List of 32-byte binary keys for v2 HKDF-derived encryption. */
43    private array $v2Keys;
44
45    /** @var list<string> List of 32-byte binary keys for v1 legacy SHA-256 encryption. */
46    private array $v1Keys;
47
48    /**
49     * AesGcmEncryptionService constructor.
50     *
51     * @param string|null $secretKey Active master encryption key or passphrase.
52     * @param list<string> $previousKeys Optional list of retired keys for decryption and rotation.
53     * @throws InvalidArgumentException When key is empty.
54     */
55    public function __construct(?string $secretKey = null, array $previousKeys = [])
56    {
57        $rawKey = $this->resolveMasterKey($secretKey);
58        if (trim($rawKey) === '') {
59            throw new InvalidArgumentException('Master encryption key cannot be empty.');
60        }
61
62        $allRawKeys = array_merge([$rawKey], $this->resolvePreviousKeys($previousKeys));
63        $v2Keys = [];
64        $v1Keys = [];
65
66        foreach ($allRawKeys as $keyCandidate) {
67            $candidate = trim($keyCandidate);
68            if ($candidate !== '') {
69                $v2Keys[] = hash_hkdf('sha256', $candidate, 32, self::HKDF_INFO);
70                $v1Keys[] = hash('sha256', $candidate, true);
71            }
72        }
73
74        $this->v2Keys = $v2Keys;
75        $this->v1Keys = $v1Keys;
76    }
77
78    /** {@inheritdoc} */
79    public function encrypt(string $plaintext): string
80    {
81        if ($plaintext === '') {
82            return '';
83        }
84
85        $iv = random_bytes(self::IV_LENGTH);
86        $tag = '';
87
88        $ciphertext = openssl_encrypt(
89            $plaintext,
90            self::CIPHER,
91            $this->v2Keys[0],
92            OPENSSL_RAW_DATA,
93            $iv,
94            $tag,
95            '',
96            self::TAG_LENGTH
97        );
98
99        if ($ciphertext === false) {
100            throw EncryptionException::forEncryptionFailure();
101        }
102
103        return self::PREFIX_V2
104            . base64_encode($iv) . ':'
105            . base64_encode($tag) . ':'
106            . base64_encode($ciphertext);
107    }
108
109    /** {@inheritdoc} */
110    public function decrypt(string $payload): string
111    {
112        if ($payload === '') {
113            return '';
114        }
115
116        if (!$this->isEncrypted($payload)) {
117            return $payload;
118        }
119
120        $isV2 = str_starts_with($payload, self::PREFIX_V2);
121        $prefix = $isV2 ? self::PREFIX_V2 : self::PREFIX_V1;
122        $candidateKeys = $isV2 ? $this->v2Keys : $this->v1Keys;
123
124        $parts = explode(':', substr($payload, strlen($prefix)));
125        if (count($parts) !== 3) {
126            throw new InvalidArgumentException('Invalid encrypted payload structure.');
127        }
128
129        $iv = base64_decode($parts[0], true);
130        $tag = base64_decode($parts[1], true);
131        $ciphertext = base64_decode($parts[2], true);
132
133        if ($iv === false || $tag === false || $ciphertext === false) {
134            throw new InvalidArgumentException('Corrupted base64 segments in payload.');
135        }
136
137        foreach ($candidateKeys as $key) {
138            $plaintext = openssl_decrypt(
139                $ciphertext,
140                self::CIPHER,
141                $key,
142                OPENSSL_RAW_DATA,
143                $iv,
144                $tag
145            );
146
147            if ($plaintext !== false) {
148                return $plaintext;
149            }
150        }
151
152        throw EncryptionException::forDecryptionFailure();
153    }
154
155    /** {@inheritdoc} */
156    public function isEncrypted(string $value): bool
157    {
158        return str_starts_with($value, self::PREFIX_V2) || str_starts_with($value, self::PREFIX_V1);
159    }
160
161    /** {@inheritdoc} */
162    public function reEncrypt(string $payload): string
163    {
164        if ($payload === '' || !$this->isEncrypted($payload)) {
165            return $payload;
166        }
167
168        $plaintext = $this->decrypt($payload);
169
170        return $this->encrypt($plaintext);
171    }
172
173    /**
174     * Resolves the primary master key from argument, environment, or persistent storage.
175     */
176    private function resolveMasterKey(?string $secretKey): string
177    {
178        if ($secretKey !== null) {
179            return $secretKey;
180        }
181
182        $envKey = (string) ($_ENV['APP_KEY'] ?? getenv('APP_KEY') ?: '');
183        if (trim($envKey) !== '') {
184            return $envKey;
185        }
186
187        $isTest = ($_ENV['APP_ENV'] ?? getenv('APP_ENV')) === 'test'
188            || defined('PHPUNIT_COMPOSER_INSTALL');
189
190        return $isTest
191            ? 'ammonly_isolated_test_suite_encryption_key_32chars!'
192            : $this->resolvePersistentKey();
193    }
194
195    /**
196     * Resolves previous keys list from arguments or environment variable APP_PREVIOUS_KEYS.
197     *
198     * @param list<string> $previousKeys Explicit previous keys.
199     * @return list<string>
200     */
201    private function resolvePreviousKeys(array $previousKeys): array
202    {
203        if ($previousKeys !== []) {
204            return $previousKeys;
205        }
206
207        $envPrev = (string) ($_ENV['APP_PREVIOUS_KEYS'] ?? getenv('APP_PREVIOUS_KEYS') ?: '');
208        if (trim($envPrev) === '') {
209            return [];
210        }
211
212        return array_values(array_filter(array_map('trim', explode(',', $envPrev))));
213    }
214
215    /**
216     * Resolves or generates a persistent instance-specific encryption key from local storage.
217     */
218    private function resolvePersistentKey(): string
219    {
220        $keyFile = dirname(__DIR__, 4) . '/storage/app.key';
221        if (file_exists($keyFile)) {
222            $content = (string) @file_get_contents($keyFile);
223            if (trim($content) !== '') {
224                return trim($content);
225            }
226        }
227
228        $newKey = bin2hex(random_bytes(32));
229        $dir = dirname($keyFile);
230        if (!is_dir($dir)) {
231            @mkdir($dir, 0775, true);
232        }
233        @file_put_contents($keyFile, $newKey);
234        @chmod($keyFile, 0660);
235
236        return $newKey;
237    }
238}
239