1 | <?php |
||||||
2 | /** |
||||||
3 | * src/Console/Commands/MigrateEncryptionCommand.php. |
||||||
4 | * |
||||||
5 | * @author Austin Heap <[email protected]> |
||||||
6 | * @version v0.2.1 |
||||||
7 | */ |
||||||
8 | declare(strict_types=1); |
||||||
9 | |||||||
10 | namespace AustinHeap\Database\Encryption\Console\Commands; |
||||||
11 | |||||||
12 | use AustinHeap\Database\Encryption\EncryptionFacade as DatabaseEncryption; |
||||||
13 | use Exception; |
||||||
14 | use Illuminate\Encryption\Encrypter; |
||||||
15 | use Illuminate\Support\Facades\Config; |
||||||
16 | use Illuminate\Support\Facades\DB; |
||||||
17 | use Illuminate\Support\Facades\Log; |
||||||
18 | use RuntimeException; |
||||||
19 | |||||||
20 | /** |
||||||
21 | * Class MigrateEncryptionCommand. |
||||||
22 | * |
||||||
23 | * This console job locates data in the database that contains data encrypted |
||||||
24 | * using a wrong/deprecated encryption key, and re-encrypts it using the |
||||||
25 | * correct/new encryption key. |
||||||
26 | * |
||||||
27 | * It can be used to fix badly encrypted data, or can be used to decrypt data using |
||||||
28 | * one key and re-encrypt using another key. |
||||||
29 | * |
||||||
30 | * ### Installation |
||||||
31 | * |
||||||
32 | * * Override this class and change the setupKeys() function to set the keys that |
||||||
33 | * are to be used ($old_keys and $new_key) as well as the array of $table names. |
||||||
34 | * |
||||||
35 | * * Add 'App\Console\Commands\MigrateEncryptionCommand' to the $commands array in |
||||||
36 | * your 'App\Console\Kernel' package. |
||||||
37 | * |
||||||
38 | * ### Example |
||||||
39 | * |
||||||
40 | * <code> |
||||||
41 | * php artisan migrate:encryption |
||||||
42 | * </code> |
||||||
43 | */ |
||||||
44 | class MigrateEncryptionCommand extends \Illuminate\Console\Command |
||||||
45 | { |
||||||
46 | /** |
||||||
47 | * The stats of the last run of the console command. |
||||||
48 | * |
||||||
49 | * @var array |
||||||
50 | */ |
||||||
51 | private static $stats = null; |
||||||
52 | |||||||
53 | /** |
||||||
54 | * The name and signature of the console command. |
||||||
55 | * |
||||||
56 | * @var string |
||||||
57 | */ |
||||||
58 | protected $signature = 'migrate:encryption'; |
||||||
59 | |||||||
60 | /** |
||||||
61 | * The console command description. |
||||||
62 | * |
||||||
63 | * @var string |
||||||
64 | */ |
||||||
65 | protected $description = 'Rotate keys used for database encryption'; |
||||||
66 | |||||||
67 | /** |
||||||
68 | * An array of old keys. Each one is to be tried in turn. |
||||||
69 | * |
||||||
70 | * @var array |
||||||
71 | */ |
||||||
72 | protected $old_keys = null; |
||||||
73 | |||||||
74 | /** |
||||||
75 | * The new encryption key. |
||||||
76 | * |
||||||
77 | * @var string |
||||||
78 | */ |
||||||
79 | protected $new_key = null; |
||||||
80 | |||||||
81 | /** |
||||||
82 | * The list of tables to be scanned. |
||||||
83 | * |
||||||
84 | * @var array |
||||||
85 | */ |
||||||
86 | protected $tables = null; |
||||||
87 | |||||||
88 | /** |
||||||
89 | * Get the configuration setting for the prefix used to determine if a string is encrypted. |
||||||
90 | * |
||||||
91 | * @return string |
||||||
92 | */ |
||||||
93 | 1 | protected function getEncryptionPrefix(): string |
|||||
94 | { |
||||||
95 | 1 | return DatabaseEncryption::getPrefix(); |
|||||
0 ignored issues
–
show
Bug
introduced
by
Loading history...
|
|||||||
96 | } |
||||||
97 | |||||||
98 | /** |
||||||
99 | * Determine whether a string has already been encrypted. |
||||||
100 | * |
||||||
101 | * @param mixed $value |
||||||
102 | * |
||||||
103 | * @return bool |
||||||
104 | */ |
||||||
105 | 1 | protected function isEncrypted($value): bool |
|||||
106 | { |
||||||
107 | 1 | return strpos((string) $value, $this->getEncryptionPrefix()) === 0; |
|||||
108 | } |
||||||
109 | |||||||
110 | /** |
||||||
111 | * Return the encrypted value of an attribute's value. |
||||||
112 | * |
||||||
113 | * @param string $value |
||||||
114 | * @param Encrypter $cipher |
||||||
115 | * |
||||||
116 | * @return null|string |
||||||
117 | */ |
||||||
118 | public function encryptedAttribute($value, $cipher): ?string |
||||||
119 | { |
||||||
120 | return $this->getEncryptionPrefix().$cipher->encrypt($value); |
||||||
121 | } |
||||||
122 | |||||||
123 | /** |
||||||
124 | * Return the decrypted value of an attribute's encrypted value. |
||||||
125 | * |
||||||
126 | * @param string $value |
||||||
127 | * @param Encrypter $cipher |
||||||
128 | * |
||||||
129 | * @return null|string |
||||||
130 | */ |
||||||
131 | public function decryptedAttribute($value, $cipher): ?string |
||||||
132 | { |
||||||
133 | return $cipher->decrypt(str_replace($this->getEncryptionPrefix(), '', $value)); |
||||||
134 | } |
||||||
135 | |||||||
136 | /** |
||||||
137 | * Set up keys. |
||||||
138 | * |
||||||
139 | * @return void |
||||||
140 | */ |
||||||
141 | 1 | protected function setupKeys() |
|||||
142 | { |
||||||
143 | // Over-ride this function to set: |
||||||
144 | // |
||||||
145 | // * $this->old_keys |
||||||
146 | // * $this->new_key |
||||||
147 | // * $this->tables |
||||||
148 | 1 | } |
|||||
149 | |||||||
150 | /** |
||||||
151 | * Execute the console command. |
||||||
152 | * |
||||||
153 | * @return mixed |
||||||
154 | */ |
||||||
155 | 5 | public function handle() |
|||||
156 | { |
||||||
157 | // Keys |
||||||
158 | 5 | $this->setupKeys(); |
|||||
159 | |||||||
160 | 5 | throw_if(! is_array($this->old_keys) || empty($this->old_keys) || count($this->old_keys) == 0, |
|||||
161 | 5 | RuntimeException::class, |
|||||
162 | 5 | 'You must override this class with (array)$old_keys set correctly.'); |
|||||
163 | 3 | throw_if(! is_string($this->new_key) || empty($this->new_key), RuntimeException::class, |
|||||
164 | 3 | 'You must override this class with (string)$new_key set correctly.'); |
|||||
165 | 2 | throw_if(! is_array($this->tables) || empty($this->tables) || count($this->tables) == 0, RuntimeException::class, |
|||||
166 | 2 | 'You must override this class with (array)$tables set correctly.'); |
|||||
167 | |||||||
168 | // Encrypter objects |
||||||
169 | 1 | $cipher = Config::get('app.cipher', 'AES-256-CBC'); |
|||||
170 | 1 | $base_encrypter = new Encrypter($this->new_key, $cipher); |
|||||
171 | 1 | $old_encrypter = []; |
|||||
172 | |||||||
173 | 1 | foreach ($this->old_keys as $key => $value) { |
|||||
174 | 1 | $old_encrypter[$key] = new Encrypter($value, $cipher); |
|||||
175 | } |
||||||
176 | |||||||
177 | // Stats |
||||||
178 | $stats = [ |
||||||
179 | 1 | 'tables' => count($this->tables), |
|||||
180 | 1 | 'rows' => 0, |
|||||
181 | 1 | 'attributes' => 0, |
|||||
182 | 1 | 'failed' => 0, |
|||||
183 | 1 | 'migrated' => 0, |
|||||
184 | 1 | 'skipped' => 0, |
|||||
185 | ]; |
||||||
186 | |||||||
187 | // Main |
||||||
188 | 1 | $this->writeln('<fg=green>Migrating <fg=blue>'.count($this->old_keys).'</> old database encryption key(s) on <fg=blue>'.$stats['tables'].'</> table(s).</>'); |
|||||
189 | |||||||
190 | 1 | foreach ($this->tables as $table_name) { |
|||||
191 | // Process table |
||||||
192 | 1 | $this->writeln('<fg=yellow>Fetching data from: <fg=white>"</><fg=green>'.$table_name.'<fg=white>"</>.</>'); |
|||||
193 | |||||||
194 | // Setup table stats |
||||||
195 | 1 | $table_stats = ['rows' => 0, 'attributes' => 0, 'failed' => 0, 'migrated' => 0, 'skipped' => 0]; |
|||||
196 | |||||||
197 | // Get count of records |
||||||
198 | 1 | $count = DB::table($table_name) |
|||||
199 | 1 | ->count(); |
|||||
200 | |||||||
201 | // Create progress bar |
||||||
202 | 1 | $bar = defined('LARAVEL_DATABASE_ENCRYPTION_TESTS') ? null : $this->output->createProgressBar($count); |
|||||
203 | |||||||
204 | 1 | $this->writeln('<fg=yellow>Found <fg=blue>'.number_format($count, 0).'</> record(s) in database; checking encryption keys.</>'); |
|||||
205 | |||||||
206 | // Get table object |
||||||
207 | 1 | $table_data = DB::table($table_name) |
|||||
208 | 1 | ->orderBy('id'); |
|||||
209 | |||||||
210 | // Cycle through table data 1k records at a time |
||||||
211 | 1 | $chunk = 1000; |
|||||
212 | $table_data->chunk($chunk, function ($data) use ( |
||||||
213 | 1 | &$stats, |
|||||
0 ignored issues
–
show
|
|||||||
214 | 1 | &$table_stats, |
|||||
215 | 1 | $bar, |
|||||
216 | 1 | $chunk, |
|||||
217 | 1 | $base_encrypter, |
|||||
218 | 1 | $old_encrypter, |
|||||
219 | 1 | $table_name |
|||||
220 | ) { |
||||||
221 | 1 | foreach ($data as $datum) { |
|||||
222 | // Check every column of the table for an encrypted value. If the value is |
||||||
223 | // encrypted then try to decrypt it with the base encrypter. |
||||||
224 | 1 | $datum_array = get_object_vars($datum); |
|||||
225 | 1 | $adjust = []; |
|||||
226 | |||||||
227 | 1 | $table_stats['rows'] += 1; |
|||||
228 | |||||||
229 | 1 | foreach ($datum_array as $key => $value) { |
|||||
230 | 1 | $table_stats['attributes'] += 1; |
|||||
231 | |||||||
232 | 1 | if (! $this->isEncrypted($value)) { |
|||||
233 | 1 | $table_stats['skipped'] += 1; |
|||||
234 | 1 | continue; |
|||||
235 | } |
||||||
236 | |||||||
237 | try { |
||||||
238 | $test = $this->decryptedAttribute($value, $base_encrypter); |
||||||
0 ignored issues
–
show
|
|||||||
239 | continue; |
||||||
240 | } catch (Exception $e) { |
||||||
241 | |||||||
242 | // If the base encrypter fails then try to decrypt it with each |
||||||
243 | // other encrypter until one works or they all fail. |
||||||
244 | $new_value = ''; |
||||||
245 | |||||||
246 | foreach ($old_encrypter as $cipher) { |
||||||
247 | try { |
||||||
248 | $test = $this->decryptedAttribute($value, $cipher); |
||||||
249 | |||||||
250 | // If that did not throw an exception then we have a match |
||||||
251 | // between the old encrypter and the encrypted value, so |
||||||
252 | // adjust the new value. |
||||||
253 | $new_value = $this->encryptedAttribute($test, $base_encrypter); |
||||||
254 | continue; |
||||||
255 | } catch (\Exception $e) { |
||||||
256 | // Do nothing, keep trying. |
||||||
257 | } |
||||||
258 | } |
||||||
259 | |||||||
260 | // If we got a match then empty($new_value) != true |
||||||
261 | if (empty($new_value)) { |
||||||
262 | Log::error( |
||||||
263 | __CLASS__.':'.__TRAIT__.':'.__FILE__.':'.__LINE__.':'.__FUNCTION__.':'. |
||||||
264 | 'Unable to find encryption key for: '.$table_name->key.' #'.$datum->id |
||||||
265 | ); |
||||||
266 | |||||||
267 | $table_stats['failed'] += 1; |
||||||
268 | continue; |
||||||
269 | } |
||||||
270 | |||||||
271 | // We got a match |
||||||
272 | $adjust[$key] = $new_value; |
||||||
273 | $table_stats['migrated'] += 1; |
||||||
274 | } |
||||||
275 | |||||||
276 | $table_stats['attributes'] += 1; |
||||||
277 | } |
||||||
278 | |||||||
279 | // If we have anything in $adjust, write that back to the database |
||||||
280 | 1 | if (count($adjust) == 0) { |
|||||
281 | 1 | continue; |
|||||
282 | } |
||||||
283 | |||||||
284 | DB::table($table_name) |
||||||
285 | ->where('id', '=', $datum->id) |
||||||
286 | ->update($adjust); |
||||||
287 | } |
||||||
288 | |||||||
289 | // Advance progress bar |
||||||
290 | 1 | if (! defined('LARAVEL_DATABASE_ENCRYPTION_TESTS')) { |
|||||
291 | $bar->advance($chunk); |
||||||
0 ignored issues
–
show
The method
advance() does not exist on null .
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces. This is most likely a typographical error or the method has been renamed.
Loading history...
|
|||||||
292 | } |
||||||
293 | 1 | }); |
|||||
294 | |||||||
295 | // Finish progress bar |
||||||
296 | 1 | if (! defined('LARAVEL_DATABASE_ENCRYPTION_TESTS')) { |
|||||
297 | $bar->finish(); |
||||||
298 | } |
||||||
299 | |||||||
300 | // And display stats |
||||||
301 | 1 | foreach ($table_stats as $key => $value) { |
|||||
302 | 1 | $stats[$key] += $value; |
|||||
303 | } |
||||||
304 | |||||||
305 | 1 | $this->writeln(''); |
|||||
306 | 1 | $this->writeln('<fg=blue>Database encryption migration for table <fg=white>"</><fg=green>'.$table_name.'</><fg=white>"</> complete: '.self::buildStatsString($table_stats).'.</>'); |
|||||
307 | } |
||||||
308 | |||||||
309 | 1 | $this->writeln('<fg=green>Database encryption migration for all <fg=blue>'.$stats['tables'].'</> table(s) complete: '.self::buildStatsString($stats).'.</>'); |
|||||
310 | 1 | self::setStats($stats); |
|||||
311 | 1 | } |
|||||
312 | |||||||
313 | 1 | private function writeln(string $line): void |
|||||
314 | { |
||||||
315 | 1 | $output = $this->getOutput(); |
|||||
316 | |||||||
317 | 1 | if (! is_null($output)) { |
|||||
318 | $output->writeln($line); |
||||||
319 | } |
||||||
320 | 1 | } |
|||||
321 | |||||||
322 | 1 | private static function buildStatsString(array $stats, string $stat = null, bool $stylize = true): string |
|||||
323 | { |
||||||
324 | 1 | $string = ''; |
|||||
325 | |||||||
326 | 1 | foreach ($stats as $key => $value) { |
|||||
327 | 1 | if (! is_null($stat) && $key != $stat) { |
|||||
328 | continue; |
||||||
329 | } |
||||||
330 | |||||||
331 | 1 | $string .= self::stylizeStatsString($key, 'fg=white', $stylize). |
|||||
332 | 1 | self::stylizeStatsString(' = ', 'fg=yellow', $stylize). |
|||||
333 | 1 | self::stylizeStatsString(is_int($value) ? number_format($value, 0) : $value, 'fg=magenta', |
|||||
334 | 1 | $stylize).'; '; |
|||||
335 | } |
||||||
336 | |||||||
337 | 1 | return empty($string) ? '' : substr($string, 0, -2); |
|||||
338 | } |
||||||
339 | |||||||
340 | 1 | private static function stylizeStatsString(string $string, string $style, bool $stylize = true): string |
|||||
341 | { |
||||||
342 | 1 | return ! $stylize ? $string : '<'.$style.'>'.$string.'</'.(strpos($style, |
|||||
343 | 1 | '<fg') === 0 ? '' : $style).'>'; |
|||||
344 | } |
||||||
345 | |||||||
346 | 1 | private static function setStats(array $stats): void |
|||||
347 | { |
||||||
348 | 1 | self::$stats = $stats; |
|||||
349 | 1 | } |
|||||
350 | |||||||
351 | 1 | public static function getStats(): array |
|||||
352 | { |
||||||
353 | 1 | throw_if(is_null(self::$stats), RuntimeException::class, 'Stats do not exist; command has not been executed.'); |
|||||
354 | |||||||
355 | 1 | return self::$stats; |
|||||
356 | } |
||||||
357 | } |
||||||
358 |