Issues (25)

src/Console/Commands/MigrateEncryptionCommand.php (2 issues)

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();
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
The import $stats is not used and could be removed.

This check looks for imports that have been defined, but are not used in the scope.

Loading history...
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
The assignment to $test is dead and can be removed.
Loading history...
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);
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