austinheap /
laravel-database-encryption
| 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 |