├── README.md └── update-encryption.php /README.md: -------------------------------------------------------------------------------- 1 | # Magento Encryption Key Rotation Tool 2 | 3 | ## Overview 4 | 5 | This script addresses the limitations of Magento's native encryption key rotation functionality, particularly in light of recent security vulnerabilities like CosmicSting. It provides a different approach to re-encrypting sensitive data across Magento databases. 6 | 7 | This script was built to aid with https://sansec.io/research/cosmicsting-hitting-major-stores and for merchants facing issues using the Adobe supplied tool. 8 | 9 | From the sansec post 10 | > Upgrading is Insufficient 11 | > As we warned in our earlier article, it is crucial for merchants to upgrade or apply the official isolated fix. At this stage however, just patching for the CosmicSting vulnerability is likely to be insufficient. 12 | > 13 | >The stolen encryption key still allows attackers to generate web tokens even after upgrading. Merchants that are currently still vulnerable should consider their encryption key as compromised. Adobe offers functionality out of the box to change the encryption key while also re-encrypting existing secrets. 14 | > 15 | >Important note: generating a new encryption key using this functionality does not invalidate the old key. We recommend manually updating the old key in app/etc/env.php to a new value rather than removing it. 16 | 17 | ## Disclaimer 18 | This tool is provided as-is, without any warranty. Use at your own risk and always test thoroughly in a non-production environment first. 19 | 20 | ## Features 21 | 22 | - Scans all database tables for encrypted values 23 | - Re-encrypts data using a new encryption key 24 | - Handles core Magento tables and custom third-party extension tables 25 | - Supports multiple encryption keys 26 | - Generates backup of current encrypted values 27 | - Option to update database directly or generate SQL update statements 28 | 29 | ## Installation 30 | 31 | 1. Clone this repository or download the `update-encryption.php` script. 32 | 2. Place the script in the root directory of your Magento installation. 33 | 34 | ## Usage 35 | 36 | ### Step 1: Scan Mode 37 | 38 | Run the script in scan mode to identify encrypted values: 39 | This will generate a CSV file listing all tables, fields, and encrypted values found. 40 | 41 | ``` 42 | php update-encryption.php scan 43 | ``` 44 | 45 | We can also do this: 46 | 47 | ``` 48 | php update-encryption.php scan --decrypt --re-encrypt --key=NEW_KEY 49 | ``` 50 | This will (try to) decrypt all values and write both encrypted with the original key, decrypted and encrypted with the new key values in encrypted-values.csv. You can change filename with --output=FILE command. 51 | 52 | ### Step 2: Re-encryption 53 | 54 | After reviewing the scan results, run the script to re-encrypt the data. 55 | This command DOES CHANGE the database!!! Be careful!! Only run it when old encryption key is written in env.php 56 | 57 | ``` 58 | update-encryption.php update-table --table=core_config_data\ 59 | --id-field=config_id --field=value --key=NEW_KEY --key-index=1\ 60 | --old-key-index=[0/1] 61 | --dump=rotation.sql 62 | --dry-run 63 | ``` 64 | 65 | Options: 66 | - `--dry-run`: Generate SQL update statements without modifying the database 67 | - `--backup`: Create a backup of current encrypted values before re-encryption 68 | 69 | Note --dry-run option, it won’t execute an update query only print it, --dump will write to a file (in the append mode, so you can have the same file for multiple tables), and will also generate a backup file. 70 | 71 | You can also update a single record, the command for that will be: 72 | 73 | ``` 74 | php update-encryption.php update-record --table=core_config_data --id-field=config_id --id=1234 --field=value --key=NEW_KEY 75 | ``` 76 | 77 | ## Important Notes 78 | 79 | - This script is designed for use by experienced Magento developers. 80 | - Always backup your database before running this script. 81 | - The script uses `fetchAll`, which may consume significant memory for large tables. 82 | - Currently only supports Sodium for encryption (legacy mcrypt values are not handled). 83 | - Encrypted values within JSON or URL parameters may be missed. 84 | 85 | ## Caution 86 | 87 | - Do not attempt to decrypt or re-encrypt hashed passwords. 88 | - Be cautious when dealing with payment information and other sensitive data. 89 | 90 | ## Limitations 91 | 92 | - May not catch all encrypted values, especially those embedded in complex data structures. 93 | - Performance may be impacted on very large databases. 94 | 95 | ## Alternative Solutions 96 | 97 | For those preferring a Magento module-based approach, consider: 98 | [Gene Commerce Encryption Key Manager](https://github.com/genecommerce/module-encryption-key-manager/) 99 | 100 | ## Contributing 101 | 102 | Contributions to improve this tool are welcome. Feel free to Fork it and submit pull requests or open issues on the GitHub repository. 103 | 104 | ## License 105 | 106 | MIT License 107 | 108 | Permission is hereby granted, free of charge, to any person obtaining a copy 109 | of this software and associated documentation files (the "Software"), to deal 110 | in the Software without restriction, including without limitation the rights 111 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 112 | copies of the Software, and to permit persons to whom the Software is 113 | furnished to do so, subject to the following conditions: 114 | 115 | The above copyright notice and this permission notice shall be included in all 116 | copies or substantial portions of the Software. 117 | 118 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 119 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 120 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 121 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 122 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 123 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 124 | SOFTWARE. 125 | -------------------------------------------------------------------------------- /update-encryption.php: -------------------------------------------------------------------------------- 1 | false, 87 | 're-encrypt' => false, 88 | 'dry-run' => false, 89 | 'dump' => false, 90 | 'magento-root' => __DIR__, 91 | 'key-number' => 1, 92 | 'old-key-number' => 0, 93 | 'output' => 'encrypted-values.csv' 94 | ]; 95 | 96 | 97 | foreach ($argv as $i => $argument) { 98 | if ($i == 0 || $i == 1) 99 | continue; 100 | if ($argument == '--decrypt') { 101 | $params['decrypt'] = true; 102 | } else if ($argument == '--re-encrypt') { 103 | $params['re-encrypt'] = true; 104 | } else if ($argument == '--dry-run') { 105 | $params['dry-run'] = true; 106 | } else if (preg_match('%--output=(.*?)$%', $argument, $m)) { 107 | $params['output'] = $m[1]; 108 | } else if (preg_match('%--key=(.*?)$%', $argument, $m)) { 109 | $params['key'] = $m[1]; 110 | } else if (preg_match('%--key-number=(\d+?)$%', $argument, $m)) { 111 | $params['key-number'] = (int)$m[1]; 112 | } else if (preg_match('%--old-key=(.*?)$%', $argument, $m)) { 113 | $params['old-key'] = $m[1]; 114 | } else if (preg_match('%--old-key-number=(\d+?)$%', $argument, $m)) { 115 | $params['old-key-number'] = (int)$m[1]; 116 | } else if (preg_match('%--id-field=(.*?)$%', $argument, $m)) { 117 | $params['id-field'] = $m[1]; 118 | } else if (preg_match('%--field=(.*?)$%', $argument, $m)) { 119 | $params['field'] = $m[1]; 120 | } else if (preg_match('%--table=(.*?)$%', $argument, $m)) { 121 | $params['table'] = $m[1]; 122 | } else if (preg_match('%--id=(.*?)$%', $argument, $m)) { 123 | $params['id'] = $m[1]; 124 | } else if (preg_match('%--dump=(.*?)$%', $argument, $m)) { 125 | $params['dump'] = $m[1]; 126 | } else if (preg_match('%--magento-root=(.*?)$%', $argument, $m)) { 127 | $params['magento-root'] = $m[1]; 128 | } else if (preg_match('%--value=(.*?)$%', $argument, $m)) { 129 | $params['value'] = $m[1]; 130 | } else if (preg_match('%--ignore-tables=(.*?)$%', $argument, $m)) { 131 | $params['ignore-tables'] = $m[1]; 132 | } 133 | 134 | } 135 | 136 | if (!file_exists($params['magento-root'] . '/app/etc/env.php')) { 137 | exit("Run the script from the magento root folder"); 138 | } 139 | 140 | require $params['magento-root'] . '/vendor/autoload.php'; 141 | 142 | $env = include $params['magento-root'] . '/app/etc/env.php'; 143 | $config = $env['db']['connection']['default']; 144 | $db = new \PDO(sprintf('mysql:host=%s;dbname=%s;', $config['host'], $config['dbname']), $config['username'], $config['password']); 145 | $key = isset($params['old-key']) && $params['old-key'] ? $params['old-key'] : $env['crypt']['key']; 146 | $keyLines = explode("\n", $key); 147 | if (!isset($params['old-key']) && is_array($keyLines)) { 148 | if (isset($keyLines[$params['old-key-number']])) 149 | $key = $keyLines[$params['old-key-number']]; 150 | else 151 | exit("OLD KEY NUMBER IS WRONG, NO KEY WITH NUMBER " . $params['old-key-number'] . " FOUND IN app/etc/env.php\n"); 152 | 153 | } 154 | 155 | $crypt = new \Magento\Framework\Encryption\Adapter\SodiumChachaIetf($key); 156 | if (isset($params['key']) && $params['key']) 157 | $cryptNew = new \Magento\Framework\Encryption\Adapter\SodiumChachaIetf($params['key']); 158 | 159 | function message($message) { 160 | echo $message . "\n"; 161 | } 162 | 163 | function definePrimaryKeyField($db, $table) 164 | { 165 | $metaInfo = $db->query("DESC `$table`")->fetchAll(PDO::FETCH_ASSOC); 166 | foreach ($metaInfo as $row) { 167 | if ($row['Key'] == 'PRI') { 168 | return $row['Field']; 169 | } 170 | } 171 | return null; 172 | } 173 | 174 | function decrypt($encrypted, $key, $keyNumber = null) 175 | { 176 | $chunks = explode(':', $encrypted); 177 | $value = null; 178 | $decryptor = null; 179 | $keyNumber = null; 180 | 181 | $numberOfChunks = count($chunks); 182 | if ($numberOfChunks === 4) { 183 | $keyNumber = $chunks[0]; 184 | $cryptVersion = $chunks[1]; 185 | if ($cryptVersion != 2) { 186 | message("UNSUPPORTED FORMAT: $encrypted. Value has four chunks, but second chunk (crypt version) is not 2 (mcrypt, MCRYPT_RIJNDAEL_256)"); 187 | return; 188 | } 189 | if (!function_exists('mdecrypt_generic')) { 190 | message("Unable to decrypt value encrypted with mcrypt because mcrypt extension is not installed."); 191 | return null; 192 | } 193 | $value = $chunks[3]; 194 | $iv = $chunks[2] ?? null; 195 | $decryptor = new \Magento\Framework\Encryption\Adapter\Mcrypt($key, MCRYPT_RIJNDAEL_256, MCRYPT_MODE_CBC, $iv); 196 | } else if ($numberOfChunks === 3) { 197 | $keyNumber = $chunks[0]; 198 | $cryptVersion = $chunks[1]; 199 | $value = $chunks[2]; 200 | if ($cryptVersion != 2 && $cryptVersion != 3) { 201 | message("UNSUPPORTED FORMAT: $encrypted. Value has three chunks, but second chunk (crypt version) is not 2 or 3 (mcrypt MCRYPT_RIJNDAEL_256 or sodium)"); 202 | return; 203 | } 204 | if ($cryptVersion == 2 && !function_exists('mdecrypt_generic')) { 205 | message("Unable to decrypt value encrypted with mcrypt because mcrypt extension is not installed."); 206 | return null; 207 | } 208 | 209 | $decryptor = $cryptVersion === 2 ? 210 | new \Magento\Framework\Encryption\Adapter\Mcrypt($key, MCRYPT_RIJNDAEL_256, MCRYPT_MODE_CBC, null) : 211 | new \Magento\Framework\Encryption\Adapter\SodiumChachaIetf($key); 212 | } else if ($numberOfChunks === 2) { 213 | //very strange format, but allowed by Magento 214 | $cryptVersion = $chunks[0]; 215 | $value = $chunks[1]; 216 | if ($cryptVersion != 2 && $cryptVersion != 3) { 217 | message("UNSUPPORTED FORMAT: $encrypted. Value has three chunks, but second chunk (crypt version) is not 2 or 3 (mcrypt MCRYPT_RIJNDAEL_256 or sodium)"); 218 | return; 219 | } 220 | if ($cryptVersion == 2 && !function_exists('mdecrypt_generic')) { 221 | message("Unable to decrypt value encrypted with mcrypt because mcrypt extension is not installed."); 222 | return null; 223 | } 224 | 225 | $decryptor = $cryptVersion === 2 ? 226 | new \Magento\Framework\Encryption\Adapter\Mcrypt($key, MCRYPT_RIJNDAEL_256, MCRYPT_MODE_CBC, null) : 227 | new \Magento\Framework\Encryption\Adapter\SodiumChachaIetf($key); 228 | } 229 | return $decryptor->decrypt(base64_decode($value)); 230 | } 231 | 232 | if ($command == 'scan') { 233 | $encryptedFields = []; 234 | $tablesToExclude = ["%^catalog%", "%amasty_xsearch_users_search%", "%url_rewrite%", "%amasty_merchandiser_product_index_eav_replica%"]; 235 | $tables = $db->query("SHOW TABLES")->fetchAll(); 236 | $f = fopen($params['output'], 'w'); 237 | fputcsv($f, ['table', 'id_field', 'id value', 'path', 'field', 'value', 'decrypted', 're-encrypted']); 238 | $ignoreTables = isset($params['ignore-tables']) ? explode(',', $params['ignore-tables']) : []; 239 | foreach ($tables as $tableRow) { 240 | $table = $tableRow[0]; 241 | $skipTable = false; 242 | foreach ($tablesToExclude as $pattern) { 243 | if (preg_match($pattern, $table)) { 244 | $skipTable = true; 245 | break; 246 | } 247 | } 248 | if ($ignoreTables && in_array($table, $ignoreTables)) { 249 | message("Ignoring table $table"); 250 | $skipTable = true; 251 | } 252 | if ($skipTable) 253 | continue; 254 | 255 | if ( ($idField = definePrimaryKeyField($db, $table)) === null) { 256 | message("SKIPPING TABLE $table, because no primary key was identified"); 257 | continue; 258 | } 259 | 260 | $data = $db->query("SELECT * FROM `$table`"); 261 | if (!$data) 262 | continue; 263 | while( ($row = $data->fetch(PDO::FETCH_ASSOC)) !== false ) { 264 | $idValue = $row[$idField]; 265 | foreach ($row as $fieldName => $value) { 266 | if (($value !== null) && preg_match("%^\d\:\d\:%", $value)) { 267 | $chunks = explode(':', $value); 268 | $decrypted = 'N/A'; 269 | $reEncrypted = 'N/A'; 270 | $path = preg_match('%core_config_data$%', $table) ? $row['path'] : 'N/A'; 271 | if ($params['decrypt']) { 272 | $decrypted = decrypt($value, $key); 273 | } 274 | if ($params['re-encrypt'] && isset($params['key'])) { 275 | $reEncrypted = sprintf("%d:3:%s", $params['key-number'], base64_encode($cryptNew->encrypt($decrypted))); 276 | } 277 | $update = [ 278 | $table, $idField, $idValue, $path, $fieldName, $value, $decrypted, $reEncrypted 279 | ]; 280 | fputcsv($f, $update); 281 | $encryptedField = sprintf("$table::$fieldName"); 282 | if (!in_array($encryptedField, $encryptedFields)) { 283 | $encryptedFields[] = $encryptedField; 284 | } 285 | } 286 | } 287 | } 288 | 289 | } 290 | print_r($encryptedFields); 291 | } else if ($command == 'update-table' || $command == 'update-record') { 292 | if (!isset($params['table'])) 293 | exit("--table option is required"); 294 | if (!isset($params['key'])) 295 | exit("--key option is required"); 296 | if (!isset($params['id-field']) && !($params['id-field'] = definePrimaryKeyField($db, $params['table']))) 297 | exit("--id-field option is missing and auto definition of a primary key failed"); 298 | if (!isset($params['field'])) 299 | exit("--field option is required"); 300 | if (isset($params['id']) && $command == 'update-table') 301 | exit("Use update-record command to update a single record"); 302 | 303 | $idField = $params['id-field']; 304 | $table = $params['table']; 305 | $field = $params['field']; 306 | 307 | message("Rotating key for a table $table, field $field. Using $idField as primary key."); 308 | 309 | $keyNumber = $params['old-key-number']; 310 | $recordFilter = ''; 311 | if ($command == 'update-record' && isset($params['id']) && ($id = (int)$params['id']) > 0) 312 | $recordFilter = sprintf(" AND `%s`='%d'", $idField, $id); 313 | $query = sprintf("SELECT * FROM `%s` WHERE `%s` LIKE '%d:3%%' OR `%s` LIKE '%d:2%%' %s", $table, $field, $keyNumber, $field, $keyNumber, $recordFilter); 314 | message($query); 315 | $data = $db->query($query); 316 | 317 | $fileHandler = null; 318 | $backupHandler = null; 319 | if (isset($params['dump']) && $params['dump']) { 320 | $fileHandler = fopen($params['dump'], 'a'); 321 | $backupHandler = fopen($params['dump'] . '.bckp', 'a'); 322 | } 323 | while ( ($row = $data->fetch(PDO::FETCH_ASSOC)) !== false) { 324 | $value = $row[$field]; 325 | $decrypted = decrypt($value, $key); 326 | if ($decrypted === null) { 327 | message("ERROR - unable to decrypt value '$value', for the record with $idField=" . $row[$idField] . ". Skipping..."); 328 | continue; 329 | } 330 | $reEncrypted = sprintf("%d:3:%s", $params['key-number'], base64_encode($cryptNew->encrypt($decrypted))); 331 | 332 | $updateQuery = sprintf("UPDATE `%s` SET `%s`='%s' WHERE `%s`='%d' LIMIT 1;", $table, $field, $reEncrypted, $idField, $row[$idField]); 333 | $backupQuery = sprintf("UPDATE `%s` SET `%s`='%s' WHERE `%s`='%d' LIMIT 1;", $table, $field, $value, $idField, $row[$idField]); 334 | 335 | if (isset($params['dump']) && $params['dump']) { 336 | fwrite($fileHandler, $updateQuery . "\n"); 337 | fwrite($backupHandler, $backupQuery . "\n"); 338 | } 339 | message($updateQuery); 340 | if (!isset($params['dry-run']) || !$params['dry-run']) { 341 | $db->query($updateQuery); 342 | message(" Done"); 343 | } 344 | } 345 | } else if ($command == 'decrypt-value') { 346 | $value = null; 347 | if (isset($params['value'])) { 348 | $value = $params['value']; 349 | } else if (isset($params['table']) && isset($params['field']) && isset($params['id'])) { 350 | $idField = definePrimaryKeyField($db, $params['table']); 351 | $record = $db->query(sprintf("SELECT * FROM `%s` WHERE %s=%d", $params['table'], $idField, (int)$params['id']))->fetchAll(); 352 | if ($record && isset($record[0])) { 353 | $value = $record[0][$params['field']]; 354 | } 355 | } 356 | $keyToDecrypt = isset($params['key']) ? $params['key'] : $key; 357 | message("Decrypted value=" . decrypt($value, $keyToDecrypt)); 358 | } 359 | --------------------------------------------------------------------------------