├── .circleci └── config.yml ├── Console ├── GenerateEncryptionKey.php ├── GetReEncryptedCloudEnvironmentsKeys.php ├── InvalidateOldEncryptionKeys.php ├── ReencryptColumn.php ├── ReencryptTfaData.php └── ReencryptUnhandledCoreConfigData.php ├── LICENSE ├── Model ├── DeploymentConfig.php ├── EncodingHelper.php ├── ReEncryptCloudEnvKeysCommand.php └── RecursiveDataProcessor.php ├── Plugin └── LogDecrypts.php ├── README.md ├── Service ├── ChangeEncryptionKey.php ├── InvalidatedKeyHasher.php └── ReencryptEnvSystemConfigurationValues.php ├── composer.json ├── dev ├── README.md └── test.sh ├── etc ├── adminhtml │ └── system.xml ├── di.xml └── module.xml └── registration.php /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | jobs: 4 | build: 5 | machine: 6 | image: default 7 | steps: 8 | - checkout 9 | - run: 10 | name: Verify Architecture 11 | command: uname -m 12 | - run: 13 | name: Log docker version 14 | command: | 15 | docker --version && docker compose version 16 | - run: 17 | name: Set up magento and run tests 18 | command: | 19 | INSTALL_LOC=$(pwd) 20 | cd /tmp/ 21 | git clone https://github.com/AmpersandHQ/magento-docker-test-instance --branch 0.1.21 22 | cd magento-docker-test-instance 23 | CURRENT_EXTENSION="$INSTALL_LOC" FULL_INSTALL=1 ./bin/mtest-make 2-4-6-p3 24 | ./bin/mtest 'cp vendor/gene/module-encryption-key-manager/dev/test.sh .' 25 | ./bin/mtest 'chmod +x ./test.sh' 26 | ./bin/mtest './test.sh' 27 | 28 | workflows: 29 | version: 2 30 | build_and_test: 31 | jobs: 32 | - build 33 | -------------------------------------------------------------------------------- /Console/GenerateEncryptionKey.php: -------------------------------------------------------------------------------- 1 | setName('gene:encryption-key-manager:generate'); 75 | $this->setDescription('Generate a new encryption key'); 76 | $this->setDefinition($options); 77 | 78 | parent::configure(); 79 | } 80 | 81 | /** 82 | * @param InputInterface $input 83 | * @param OutputInterface $output 84 | * @return int 85 | */ 86 | protected function execute(InputInterface $input, OutputInterface $output): int 87 | { 88 | $newKey = null; 89 | if ($input->getOption(self::INPUT_KEY_KEY)) { 90 | $newKey = $input->getOption(self::INPUT_KEY_KEY); 91 | $output->writeln('The provided crypt key will be used for re-encryption.'); 92 | } else { 93 | $output->writeln('A new key will be generated for re-encryption, use "--key" to specify a custom key.'); 94 | } 95 | 96 | if (!$input->getOption(self::INPUT_KEY_FORCE)) { 97 | $output->writeln('Run with --force to generate a new key. This will decrypt and reencrypt values in core_config_data and saved credit card info'); 98 | return Cli::RETURN_FAILURE; 99 | } 100 | 101 | try { 102 | $countOfKeys = count(explode(PHP_EOL, $this->encryptor->exportKeys())); 103 | $output->writeln("The system currently has $countOfKeys keys"); 104 | 105 | /** 106 | * This is heavily based on the below 107 | * 108 | * @see \Magento\EncryptionKey\Controller\Adminhtml\Crypt\Key\Save::execute() 109 | */ 110 | try { 111 | $this->state->setAreaCode('adminhtml'); 112 | } catch (\Magento\Framework\Exception\LocalizedException $exception) { 113 | // Area code is already set 114 | } 115 | $this->emulation->startEnvironmentEmulation(0, 'adminhtml'); 116 | $output->writeln('Generating a new encryption key using the magento core class'); 117 | $this->changeEncryptionKey->setOutput($output); 118 | $this->changeEncryptionKey->setSkipSavedCreditCards( 119 | (bool)$input->getOption(self::INPUT_SKIP_SAVED_CREDIT_CARDS) 120 | ); 121 | $this->changeEncryptionKey->changeEncryptionKey($newKey); 122 | $output->writeln('reEncryptEnvConfigurationValues - start'); 123 | $this->reencryptEnvSystemConfigurationValues->execute(); 124 | $output->writeln('reEncryptEnvConfigurationValues - end'); 125 | $this->emulation->stopEnvironmentEmulation(); 126 | $output->writeln('Cleaning cache'); 127 | 128 | $value = $this->scopeConfig->getValue('gene/encryption_key_manager/invalidated_key_index'); 129 | if ($value == null) { 130 | $this->configWriter->save( 131 | 'gene/encryption_key_manager/invalidated_key_index', 132 | (int) $countOfKeys - 1 133 | ); 134 | } 135 | 136 | $this->cache->clean(); 137 | $output->writeln('Done'); 138 | } catch (\Throwable $throwable) { 139 | $output->writeln("" . $throwable->getMessage() . ""); 140 | $output->writeln($throwable->getTraceAsString(), OutputInterface::VERBOSITY_VERBOSE); 141 | return Cli::RETURN_FAILURE; 142 | } 143 | return Cli::RETURN_SUCCESS; 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /Console/GetReEncryptedCloudEnvironmentsKeys.php: -------------------------------------------------------------------------------- 1 | setName('gene:encryption-key-manager:get-cloud-keys'); 40 | $this->setDescription('Reencrypt cloud encrypted keys based on $_ENV variable. ' . 41 | 'The CLI command don\'t save new values. It has to be done manually.'); 42 | $this->setDefinition([ 43 | new InputOption( 44 | self::INPUT_KEY_SHOW_DECRYPTED, 45 | null, 46 | InputOption::VALUE_NONE, 47 | 'Whether to show decrypted values.' 48 | ), 49 | ]); 50 | parent::configure(); 51 | } 52 | 53 | /** 54 | * Execute the command 55 | * 56 | * @param InputInterface $input 57 | * @param OutputInterface $output 58 | * 59 | * @return int 60 | */ 61 | protected function execute(InputInterface $input, OutputInterface $output): int 62 | { 63 | $showDecrypted = !!$input->getOption(self::INPUT_KEY_SHOW_DECRYPTED); 64 | 65 | try { 66 | // get old encrypted, decrypted and new encrypted values 67 | $config = $this->reencryptCloudEnvKeysCommand->execute(); 68 | 69 | if (!count($config)) { 70 | $output->writeln('There is no old encrypted environment variables found'); 71 | return CLI::RETURN_SUCCESS; 72 | } 73 | 74 | $output->writeln("The CLI command doesn't rewrite values. " . 75 | "You have to update them manually in cloud console!"); 76 | $output->writeln("Rows count: " . count($config) . ""); 77 | 78 | foreach ($config as $name => $arr) { 79 | $output->writeln(str_pad('', 120, '#')); 80 | 81 | /** @var $arr array{value:string, newValue:string, decryptedValue:string} */ 82 | $output->writeln("Name: {$name}"); 83 | if ($showDecrypted) { 84 | $output->writeln("Dectypted value: {$arr['decryptedValue']}"); 85 | } 86 | $output->writeln("Old Encrypted Value: {$arr['value']}"); 87 | $output->writeln("New Encrypted Value: {$arr['newValue']}"); 88 | } 89 | 90 | } catch (\Exception|\Throwable $e) { 91 | $this->logger->critical("Something went wrong while trying to reencrypt cloud variables.", [ 92 | 'msg' => $e->getMessage(), 93 | 'trace' => $e->getTraceAsString(), 94 | ]); 95 | $output->writeln("" . $e->getMessage() . ""); 96 | 97 | return CLI::RETURN_FAILURE; 98 | } 99 | 100 | return CLI::RETURN_SUCCESS; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Console/InvalidateOldEncryptionKeys.php: -------------------------------------------------------------------------------- 1 | setName('gene:encryption-key-manager:invalidate'); 50 | $this->setDescription('Invalidate old encryption keys'); 51 | $this->setDefinition($options); 52 | 53 | parent::configure(); 54 | } 55 | 56 | /** 57 | * @param InputInterface $input 58 | * @param OutputInterface $output 59 | * @return int 60 | */ 61 | protected function execute(InputInterface $input, OutputInterface $output): int 62 | { 63 | if (!$input->getOption(self::INPUT_KEY_FORCE)) { 64 | $output->writeln('Run with --force to invalidate old keys. You need to have thoroughly reviewed your entire site and database before doing this.'); 65 | return Cli::RETURN_FAILURE; 66 | } 67 | 68 | try { 69 | /** 70 | * This is largely based on how the env.php is handled in 71 | * @see \Magento\EncryptionKey\Model\ResourceModel\Key\Change::changeEncryptionKey() 72 | */ 73 | if (!$this->writer->checkIfWritable()) { 74 | throw new \Exception('Deployment configuration file is not writable.'); 75 | } 76 | 77 | $keys = $this->deploymentConfig->get('crypt/key'); 78 | $keys = preg_split('/\s+/s', trim((string)$keys)); 79 | if (count($keys) <= 1) { 80 | throw new \Exception('Cannot invalidate when there is only one key'); 81 | } 82 | 83 | $invalidatedKeys = $this->deploymentConfig->get('crypt/invalidated_key'); 84 | $invalidatedKeys = array_filter(preg_split('/\s+/s', trim((string)$invalidatedKeys))); 85 | 86 | /** 87 | * All but the latest encryption key needs to be invalidated 88 | * - Wipe out the text so that its no longer a valid key 89 | * - keep a record of it for storing in 'crypt/invalidated_key' 90 | */ 91 | $changes = false; 92 | $keySize = SODIUM_CRYPTO_AEAD_CHACHA20POLY1305_IETF_KEYBYTES; 93 | foreach ($keys as $id => $key) { 94 | if ($id === count($keys) - 1) { 95 | break; // last key needs to remain usable 96 | } 97 | if (str_starts_with($key, 'invalid')) { 98 | continue; // already been invalidated 99 | } 100 | $changes = true; 101 | $invalidatedKeys[] = $key; // this key needs to be added to the invalidated list 102 | $newKey = 'invalid' . $this->random->getRandomString($keySize - 7); 103 | $keys[$id] = $newKey; 104 | if (strlen($keys[$id]) !== $keySize) { 105 | throw new \Exception('Failed to invalidate the key with an appropriate length'); 106 | } 107 | } 108 | unset($id, $key); 109 | 110 | if (!$changes) { 111 | $output->writeln('No further keys need invalidated'); 112 | return Cli::RETURN_SUCCESS; 113 | } 114 | 115 | $output->writeln('Writing crypt/invalidated_key to env.php'); 116 | $encryptInvalidSegment = new ConfigData(ConfigFilePool::APP_ENV); 117 | $encryptInvalidSegment->set('crypt/invalidated_key', implode(PHP_EOL, $invalidatedKeys)); 118 | $this->writer->saveConfig([$encryptInvalidSegment->getFileKey() => $encryptInvalidSegment->getData()]); 119 | 120 | $output->writeln('Writing crypt/key to env.php'); 121 | $encryptSegment = new ConfigData(ConfigFilePool::APP_ENV); 122 | $encryptSegment->set('crypt/key', implode(PHP_EOL, $keys)); 123 | $this->writer->saveConfig([$encryptSegment->getFileKey() => $encryptSegment->getData()]); 124 | $this->cache->clean(); 125 | $output->writeln('Done'); 126 | } catch (\Throwable $throwable) { 127 | $output->writeln("" . $throwable->getMessage() . ""); 128 | $output->writeln($throwable->getTraceAsString(), OutputInterface::VERBOSITY_VERBOSE); 129 | return Cli::RETURN_FAILURE; 130 | } 131 | return Cli::RETURN_SUCCESS; 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /Console/ReencryptColumn.php: -------------------------------------------------------------------------------- 1 | setName('gene:encryption-key-manager:reencrypt-column'); 75 | $this->setDescription('Re-encrypt a columns data with the latest key'); 76 | $this->setDefinition($options); 77 | 78 | parent::configure(); 79 | } 80 | 81 | /** 82 | * @param InputInterface $input 83 | * @param OutputInterface $output 84 | * @return int 85 | */ 86 | protected function execute(InputInterface $input, OutputInterface $output): int 87 | { 88 | if (!$input->getOption(self::INPUT_KEY_FORCE)) { 89 | $output->writeln('Run with --force to make these changes, this will run in dry-run mode by default'); 90 | } 91 | 92 | try { 93 | $keys = preg_split('/\s+/s', trim((string)$this->deploymentConfig->get('crypt/key'))); 94 | $latestKeyNumber = count($keys) - 1; 95 | $output->writeln("The latest encryption key is number $latestKeyNumber, looking for old entries"); 96 | 97 | $table = $input->getArgument(self::INPUT_KEY_TABLE); 98 | if (!strlen($table)) { 99 | throw new \Exception('Provide a table name'); 100 | } 101 | if (in_array($table, ['core_config_data', 'tfa_user_config'])) { 102 | throw new \Exception('You cannot use this command for this table'); 103 | } 104 | $identifier = $input->getArgument(self::INPUT_KEY_IDENTIFIER); 105 | if (!strlen($identifier)) { 106 | throw new \Exception('Provide an identifier'); 107 | } 108 | $column = $input->getArgument(self::INPUT_KEY_COLUMN); 109 | if (!strlen($column)) { 110 | throw new \Exception('Provide an column'); 111 | } 112 | $jsonField = null; 113 | if (strpos($column, '.') !== false) { 114 | list($column, $jsonField) = explode('.', $column); 115 | $output->writeln( 116 | "Looking for JSON field '$jsonField.$column' in '$table', identified by '$identifier'" 117 | ); 118 | } else { 119 | $output->writeln("Looking for '$column' in '$table', identified by '$identifier'"); 120 | } 121 | /** 122 | * @see \Magento\Framework\Model\ResourceModel\Db\AbstractDb::_getLoadSelect() 123 | */ 124 | $tableName = $this->resourceConnection->getTableName($table); 125 | $connection = $this->resourceConnection->getConnection(); 126 | 127 | if (!$connection->isTableExists($tableName)) { 128 | $output->writeln("The table {$tableName} doesn't exist"); 129 | return Cli::RETURN_SUCCESS; 130 | } 131 | 132 | $field = $connection->quoteIdentifier(sprintf('%s.%s', $tableName, $column)); 133 | $select = $connection->select() 134 | ->from($tableName, [$identifier, "$column"]); 135 | if ($jsonField === null) { 136 | $select = $select->where("($field LIKE '_:_:____%' OR $field LIKE '__:_:____%')") 137 | ->where("$field NOT LIKE ?", "$latestKeyNumber:_:__%"); 138 | } else { 139 | $select = $select->where("($field LIKE '{%_:_:____%}' OR $field LIKE '{%__:_:____%}')"); 140 | } 141 | $result = $connection->fetchAll($select); 142 | if (empty($result)) { 143 | $output->writeln('No old entries found'); 144 | return Cli::RETURN_SUCCESS; 145 | } 146 | $connection->beginTransaction(); 147 | $noResults = true; 148 | foreach ($result as $row) { 149 | $output->writeln(str_pad('', 120, '#')); 150 | $value = $row[$column]; 151 | $fieldData = []; 152 | if ($jsonField !== null) { 153 | $fieldData = $this->jsonSerializer->unserialize($value); 154 | $value = $fieldData[$jsonField] ?? ''; 155 | // Prevent re-processing fields & processing empty fields 156 | if (strpos($value, "$latestKeyNumber:") === 0 || $value === '') { 157 | continue; 158 | } 159 | } 160 | $noResults = false; 161 | $output->writeln("$identifier: {$row[$identifier]}"); 162 | $output->writeln("ciphertext_old: " . $value); 163 | $valueDecrypted = $this->encryptor->decrypt($value); 164 | $output->writeln("plaintext: " . $valueDecrypted); 165 | $valueEncrypted = $this->encryptor->encrypt($valueDecrypted); 166 | $output->writeln("ciphertext_new: " . $valueEncrypted); 167 | 168 | if ($jsonField !== null) { 169 | $fieldData[$jsonField] = $valueEncrypted; 170 | $valueEncrypted = $this->jsonSerializer->serialize($fieldData); 171 | } 172 | 173 | if ($input->getOption(self::INPUT_KEY_FORCE)) { 174 | $connection->update( 175 | $tableName, 176 | [$column => $valueEncrypted], 177 | ["$identifier = ?" => $row[$identifier]] 178 | ); 179 | } else { 180 | $output->writeln('Dry run mode, no changes have been made'); 181 | } 182 | $output->writeln(str_pad('', 120, '#')); 183 | } 184 | 185 | if ($noResults) { 186 | $output->writeln('No old entries found'); 187 | } 188 | 189 | $connection->commit(); 190 | $this->cache->clean(); 191 | $output->writeln('Done'); 192 | } catch (\Throwable $throwable) { 193 | if ($this->resourceConnection->getConnection()->getTransactionLevel() > 0) { 194 | $this->resourceConnection->getConnection()->rollBack(); 195 | } 196 | $output->writeln("" . $throwable->getMessage() . ""); 197 | $output->writeln($throwable->getTraceAsString(), OutputInterface::VERBOSITY_VERBOSE); 198 | return Cli::RETURN_FAILURE; 199 | } 200 | return Cli::RETURN_SUCCESS; 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /Console/ReencryptTfaData.php: -------------------------------------------------------------------------------- 1 | setName('gene:encryption-key-manager:reencrypt-tfa-data'); 54 | $this->setDescription('Re-encrypts tfa_user_config data with the latest key'); 55 | $this->setDefinition($options); 56 | 57 | parent::configure(); 58 | } 59 | 60 | /** 61 | * @param InputInterface $input 62 | * @param OutputInterface $output 63 | * @return int 64 | */ 65 | protected function execute(InputInterface $input, OutputInterface $output): int 66 | { 67 | if (!$input->getOption(self::INPUT_KEY_FORCE)) { 68 | $output->writeln('Run with --force to make these changes, this will run in dry-run mode by default'); 69 | $output->writeln('This CLI has only been tested with Google Authenticator (TOTP) and U2F (Yubikey, etc). If you use Authy or DUO you *MUST* verify before use.'); 70 | } 71 | 72 | try { 73 | $keys = preg_split('/\s+/s', trim((string)$this->deploymentConfig->get('crypt/key'))); 74 | $latestKeyNumber = count($keys) - 1; 75 | $output->writeln("The latest encryption key is number $latestKeyNumber, looking for old entries"); 76 | 77 | $table = self::TFA_TABLE; 78 | $identifier = 'config_id'; 79 | $column = 'encoded_config'; 80 | $output->writeln("Looking for $column in $table, identified by '$identifier'"); 81 | 82 | /** 83 | * @see \Magento\Framework\Model\ResourceModel\Db\AbstractDb::_getLoadSelect() 84 | */ 85 | $tableName = $this->resourceConnection->getTableName($table); 86 | $connection = $this->resourceConnection->getConnection(); 87 | if (!$connection->isTableExists($tableName)) { 88 | $output->writeln("The table {$tableName} doesn't exist"); 89 | return Cli::RETURN_SUCCESS; 90 | } 91 | $field = $connection->quoteIdentifier(sprintf('%s.%s', $tableName, $column)); 92 | 93 | $select = $connection->select() 94 | ->from($tableName, [$identifier, "$column"]) 95 | ->where("($field LIKE '_:_:____%' OR $field LIKE '__:_:____%')") 96 | ->where("$field NOT LIKE ?", "$latestKeyNumber:_:__%"); 97 | 98 | $result = $connection->fetchAll($select); 99 | if (empty($result)) { 100 | $output->writeln('No old entries found'); 101 | return Cli::RETURN_SUCCESS; 102 | } 103 | $connection->beginTransaction(); 104 | foreach ($result as $row) { 105 | $output->writeln(str_pad('', 120, '#')); 106 | $output->writeln("$identifier: {$row[$identifier]}"); 107 | $value = $row[$column]; 108 | $output->writeln("ciphertext_old: " . $value); 109 | $valueDecrypted = $this->encryptor->decrypt($value); 110 | if (empty($valueDecrypted)) { 111 | $output->writeln("plaintext_old: is null"); 112 | continue; 113 | } 114 | $output->writeln("plaintext_old: " . $valueDecrypted); 115 | $valueDecrypted = json_decode($valueDecrypted); 116 | 117 | /** 118 | * Google Authenticator 2FA provider uses a nested encrypted value. So that we can also handle other 119 | * providers that may do the same, recursively process the originally decrypted value, re-encrypting 120 | * its children. 121 | */ 122 | $valueDecrypted = $this->recursiveDataProcessor->down($valueDecrypted); 123 | $valueDecrypted = json_encode($valueDecrypted); 124 | $output->writeln("plaintext_new: " . $valueDecrypted); 125 | $valueEncrypted = $this->encryptor->encrypt($valueDecrypted); 126 | $output->writeln("ciphertext_new: " . $valueEncrypted); 127 | 128 | if ($input->getOption(self::INPUT_KEY_FORCE)) { 129 | $connection->update( 130 | $tableName, 131 | [$column => $valueEncrypted], 132 | ["$identifier = ?" => $row[$identifier]] 133 | ); 134 | } else { 135 | $output->writeln('Dry run mode, no changes have been made'); 136 | } 137 | $output->writeln(str_pad('', 120, '#')); 138 | } 139 | if ($this->recursiveDataProcessor->hasFailures()) { 140 | $output->writeln('We encountered some problems re-encrypting values in the tfa_user_config table which requires manual intervention'); 141 | } 142 | $connection->commit(); 143 | $this->cache->clean(); 144 | $output->writeln('Done'); 145 | } catch (\Throwable $throwable) { 146 | if ($this->resourceConnection->getConnection()->getTransactionLevel() > 0) { 147 | $this->resourceConnection->getConnection()->rollBack(); 148 | } 149 | $output->writeln("" . $throwable->getMessage() . ""); 150 | $output->writeln($throwable->getTraceAsString(), OutputInterface::VERBOSITY_VERBOSE); 151 | return Cli::RETURN_FAILURE; 152 | } 153 | return Cli::RETURN_SUCCESS; 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /Console/ReencryptUnhandledCoreConfigData.php: -------------------------------------------------------------------------------- 1 | setName('gene:encryption-key-manager:reencrypt-unhandled-core-config-data'); 49 | $this->setDescription('Re-encrypt unhandled core config data with the latest key'); 50 | $this->setDefinition($options); 51 | 52 | parent::configure(); 53 | } 54 | 55 | /** 56 | * @param InputInterface $input 57 | * @param OutputInterface $output 58 | * @return int 59 | */ 60 | protected function execute(InputInterface $input, OutputInterface $output): int 61 | { 62 | if (!$input->getOption(self::INPUT_KEY_FORCE)) { 63 | $output->writeln('Run with --force to make these changes, this will run in dry-run mode by default'); 64 | } 65 | 66 | try { 67 | $keys = preg_split('/\s+/s', trim((string)$this->deploymentConfig->get('crypt/key'))); 68 | $latestKeyNumber = count($keys) - 1; 69 | 70 | $output->writeln("The latest encryption key is number $latestKeyNumber, looking for old entries"); 71 | 72 | /* 73 | * Get all values which look like an encrypted value, that are not for the latest key 74 | */ 75 | $ccdTable = $this->resourceConnection->getTableName('core_config_data'); 76 | $connection = $this->resourceConnection->getConnection(); 77 | 78 | if (!$connection->isTableExists($ccdTable)) { 79 | $output->writeln("The table {$ccdTable} doesn't exist"); 80 | return Cli::RETURN_SUCCESS; 81 | } 82 | 83 | $select = $connection->select() 84 | ->from($ccdTable, ['*']) 85 | ->where('(value LIKE "_:_:____%" OR value LIKE "__:_:____%")') 86 | ->where('value NOT LIKE ?', "$latestKeyNumber:_:__%") 87 | ->where('value NOT LIKE ?', "a:%") 88 | ->where('value NOT LIKE ?', "s:%"); 89 | 90 | $result = $connection->fetchAll($select); 91 | if (empty($result)) { 92 | $output->writeln('No old entries found'); 93 | return Cli::RETURN_SUCCESS; 94 | } 95 | foreach ($result as $row) { 96 | $output->writeln(str_pad('', 120, '#')); 97 | $output->writeln("config_id: {$row['config_id']}"); 98 | foreach ($row as $field => $value) { 99 | if (in_array($field, ['value', 'config_id'])) { 100 | continue; 101 | } 102 | $output->writeln("$field: $value"); 103 | } 104 | $value = $row['value']; 105 | $output->writeln("ciphertext_old: " . $value); 106 | $valueDecrypted = $this->encryptor->decrypt($value); 107 | $output->writeln("plaintext: " . $valueDecrypted); 108 | $valueEncrypted = $this->encryptor->encrypt($valueDecrypted); 109 | $output->writeln("ciphertext_new: " . $valueEncrypted); 110 | 111 | if ($input->getOption(self::INPUT_KEY_FORCE)) { 112 | $connection->update( 113 | $ccdTable, 114 | ['value' => $valueEncrypted], 115 | ['config_id = ?' => (int)$row['config_id']] 116 | ); 117 | } else { 118 | $output->writeln('Dry run mode, no changes have been made'); 119 | } 120 | $output->writeln(str_pad('', 120, '#')); 121 | } 122 | 123 | $this->cache->clean(); 124 | $output->writeln('Done'); 125 | } catch (\Throwable $throwable) { 126 | $output->writeln("" . $throwable->getMessage() . ""); 127 | $output->writeln($throwable->getTraceAsString(), OutputInterface::VERBOSITY_VERBOSE); 128 | return Cli::RETURN_FAILURE; 129 | } 130 | return Cli::RETURN_SUCCESS; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | -------------------------------------------------------------------------------- /Model/DeploymentConfig.php: -------------------------------------------------------------------------------- 1 | deploymentConfig->get('crypt/key'))); 31 | } catch (\Exception) { 32 | return 0; 33 | } 34 | return count($keys) -1; 35 | } 36 | 37 | /** 38 | * Validate whether the value looks like digit:digit:string 39 | * 40 | * @param string $value 41 | * @return bool 42 | */ 43 | public function isEncryptedValue(string $value): bool 44 | { 45 | preg_match('/^\d:\d:\S+/', $value, $matches); 46 | return !!count($matches); 47 | } 48 | 49 | /** 50 | * Returns whether the value is already encrypted 51 | * 52 | * @param string $encryptedValue 53 | * @return bool 54 | */ 55 | public function isAlreadyUpdated(string $encryptedValue): bool 56 | { 57 | return str_starts_with($encryptedValue, $this->getLatestKeyNumber() . ":"); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Model/ReEncryptCloudEnvKeysCommand.php: -------------------------------------------------------------------------------- 1 | placeholder = $placeholderFactory->create(PlaceholderFactory::TYPE_ENVIRONMENT); 33 | } 34 | 35 | /** 36 | * Execute the command 37 | * 38 | * @param array|null $environmentVariables 39 | * @return array 40 | * @throws \Exception 41 | */ 42 | public function execute(?array $environmentVariables = null): array 43 | { 44 | if ($environmentVariables === null) { 45 | if (!isset($_ENV)) { 46 | throw new \Exception("No environment variables defined"); 47 | } 48 | $environmentVariables = $_ENV; 49 | } 50 | 51 | $config = []; 52 | 53 | foreach ($environmentVariables as $template => $value) { 54 | if (!$this->placeholder->isApplicable($template) 55 | || !$this->helper->isEncryptedValue($value) 56 | || $this->helper->isAlreadyUpdated($value)) { 57 | continue; 58 | } 59 | 60 | $decryptedValue = $this->encryptor->decrypt($value); 61 | $newValue = $this->encryptor->encrypt($decryptedValue); 62 | $config[$template] = compact('value', 'newValue', 'decryptedValue'); 63 | } 64 | 65 | return $config; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Model/RecursiveDataProcessor.php: -------------------------------------------------------------------------------- 1 | $value) { 27 | if (is_array($value) || is_object($value)) { 28 | // If either array or object go down a level and process it and its children 29 | $value = $this->down($value); 30 | } else { 31 | // Only need to process encrypted strings 32 | if (is_string($value)) { 33 | // Previous iteration may have $matches so unset 34 | unset($matches); 35 | // Match on string that look encrypted (digit:digit:non_whitespace(1 or more) 36 | preg_match('/\d:\d:\S+/', $value, $matches); 37 | // If match found $matches[0] will exist 38 | if (isset($matches[0])) { 39 | // Re-encrypt value 40 | $decryptedValue = $this->encryptor->decrypt($value); 41 | if ($decryptedValue === '') { 42 | /** 43 | * \Magento\Framework\Encryption\Encryptor::decrypt seems to return '' on failure 44 | */ 45 | $this->failures++; 46 | } else { 47 | $value = $this->encryptor->encrypt($decryptedValue); 48 | } 49 | // Remove $decryptedValue for future iterations 50 | unset($decryptedValue); 51 | } 52 | } 53 | } 54 | // Set value back to parent 55 | $this->setValue($layer, $key, $value); 56 | } 57 | return $layer; 58 | } 59 | 60 | /** 61 | * Wrapper to set value back to parent as element write syntax differs by type 62 | * 63 | * @param mixed $parent 64 | * @param mixed $key 65 | * @param mixed $value 66 | * @return mixed 67 | */ 68 | private function setValue($parent, $key, $value) 69 | { 70 | if (is_array($parent)) { 71 | $parent[$key] = $value; 72 | } else { 73 | // $parent is object 74 | $parent->$key = $value; 75 | } 76 | return $parent; 77 | } 78 | 79 | /** 80 | * Did we encounter any cases were we were unable to decrypt & re-encrypt stored values 81 | * @return bool 82 | */ 83 | public function hasFailures(): bool 84 | { 85 | return $this->failures > 0; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Plugin/LogDecrypts.php: -------------------------------------------------------------------------------- 1 | keyCount = count(explode(PHP_EOL, $encryptor->exportKeys())) - 1; 35 | 36 | // These need to come from deployment config because it is triggered so early in the request flow 37 | $this->enabled = (bool)$deploymentConfig->get( 38 | 'system/default/dev/debug/gene_encryption_manager_enable_decrypt_logging', 39 | false 40 | ); 41 | $this->onlyLogOldKeyDecryptions = (bool)$deploymentConfig->get( 42 | 'system/default/dev/debug/gene_encryption_manager_only_log_old_decrypts', 43 | false 44 | ); 45 | } 46 | 47 | /** 48 | * Log the source of a decryption, so that we can verify all keys are properly rotated 49 | * 50 | * @param Encryptor $subject 51 | * @param $result 52 | * @param $data 53 | * @return mixed 54 | */ 55 | public function afterDecrypt(Encryptor $subject, $result, $data) 56 | { 57 | if (!$this->enabled) { 58 | return $result; 59 | } 60 | try { 61 | if (!(is_string($data) && strlen($data) > 5)) { 62 | // Not a string matching '0:0:X' or longer 63 | return $result; 64 | } 65 | if ($this->onlyLogOldKeyDecryptions && str_starts_with($data, $this->keyCount . ':')) { 66 | // We are decrypting a value identified by the current maximum key, no need to log 67 | return $result; 68 | } 69 | 70 | // don't logging values don't like as an encrypted value 71 | if (!$this->encodingHelper->isEncryptedValue($data)) { 72 | return $result; 73 | } 74 | 75 | $exception = new \Exception(); 76 | 77 | /** 78 | * Generate a trace identifier based on the stack trace excluding any args 79 | */ 80 | $traceIdentifier = []; 81 | foreach ($exception->getTrace() as $trace) { 82 | unset($trace['args']); 83 | $traceIdentifier[] = implode($trace); 84 | } 85 | $traceIdentifier = md5(implode($traceIdentifier)); 86 | 87 | /** 88 | * This is a bit odd looking but it puts the entire trace in one line, many log management systems do not 89 | * like multi line logs and this can make it a bit easier to trace / filter in those systems 90 | * 91 | * Make the log entry single pipe separated line 92 | * Remove full path from trace for easier reading 93 | * BP defined at app/autoload.php 94 | */ 95 | $traceString = str_replace(PHP_EOL, '|', $exception->getTraceAsString()); 96 | $traceString = '|' . str_replace(BP . '/', '', $traceString); 97 | 98 | $this->logger->info( 99 | 'gene encryption manager - legacy decryption', 100 | [ 101 | 'trace_id' => $traceIdentifier, 102 | 'trace' => $traceString 103 | ] 104 | ); 105 | } catch (\Throwable $throwable) { 106 | $this->logger->error( 107 | 'gene encryption manager - error logging', 108 | [ 109 | 'message' => $throwable->getMessage(), 110 | ] 111 | ); 112 | } 113 | return $result; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # module-encryption-key-manager 2 | 3 | [![](https://circleci.com/gh/genecommerce/module-encryption-key-manager.svg?style=svg)](https://circleci.com/gh/genecommerce/module-encryption-key-manager) 4 | 5 | This module was built to aid with https://sansec.io/research/cosmicsting-hitting-major-stores 6 | 7 | From the sansec post 8 | > Upgrading is Insufficient 9 | > 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. 10 | > 11 | >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. 12 | > 13 | >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. 14 | 15 | Even with your store secured, there is the chance that a JWT was issued and may still be valid. Merchants are strongly encouraged to rotate their encryption key to be safe, and the Magento process of generating a new encryption key does not actually invalidate the old one. 16 | 17 | This module is provided as-is without any warranty. Test this on your local instances, then staging, then production. Use at your own risk. 18 | 19 | This module **does not conflict** with the [new hotfix](https://experienceleague.adobe.com/en/docs/commerce-knowledge-base/kb/troubleshooting/known-issues-patches-attached/security-update-available-for-adobe-commerce-apsb24-40-revised-to-include-isolated-patch-for-cve-2024-34102?#hotfix) released by Adobe. Both this module and that hotfix improve security in the same way, by making `SecretBasedJwksFactory` use the most recent key. This module also provides additional tooling and improvements, please read below. 20 | 21 | # Installation 22 | ``` 23 | composer require gene/module-encryption-key-manager 24 | bin/magento setup:upgrade 25 | ``` 26 | 27 | # How to Rotate your key and protect your store 28 | 29 | This is a rough list of steps that should be followed to prevent attacks with CosmicSting. Please read all of the steps carefully to understand the features this module provides, as well as the points of risk. 30 | 31 | ## Generate a new Key and prevent old ones from being used for JWT 32 | 33 | This should be every merchant's **priority!** Install this module and generate a new key with: 34 | 35 | `php bin/magento gene:encryption-key-manager:generate [--key=MY_32_CHAR_CRYPT_KEY] [--skip-saved-credit-cards]` 36 | 37 | This will force the JWT factory to use the newly generated key. Other areas of the application may continue to use the old keys. This step is the absolute priority and will help prevent attacks with CosmicSting. 38 | 39 | - Use the `--key` option to manually define the new key to use during re-encryption. If no custom key is provided, a new key will be generated. 40 | - Use the `--skip-saved-credit-cards` flag to skip re-encrypting the `sales_order_payment` `cc_number_enc` data. This table can be very large, and many stores will have no data saved in this column. 41 | 42 | ## Fully rotate your old keys 43 | 44 | You can take your time to do the following. You are safe from cosmicsting provided you have installed the [isolated patches](https://experienceleague.adobe.com/en/docs/commerce-knowledge-base/kb/troubleshooting/known-issues-patches-attached/security-update-available-for-adobe-commerce-apsb24-40-revised-to-include-isolated-patch-for-cve-2024-34102#isolated-patch-details) and used this module to generate a new encryption key. 45 | 46 | Then you are free to decide if you wish to re-encrypt your old data, and then invalidate your old key. 47 | 48 | 1. **Review your database** (make sure zgrep is on version 1.12) for any tables with encrypted values. Make sure your dump is `--human-readable` (magerun) or `--extended-insert=FALSE` (mysqldump) so that all values are on the same line as the `INSERT INTO` 49 | ```bash 50 | $ zgrep -P "VALUES\s*\(.*\d:\d:...*'" database.sql | awk '{print $3}' | uniq 51 | admin_user 52 | core_config_data 53 | customer_entity 54 | oauth_token 55 | oauth_consumer 56 | tfa_user_config 57 | admin_adobe_ims_webapi 58 | adobe_user_profile 59 | ``` 60 | 61 | Or to get a overview of all found tables with amount of records: 62 | ```bash 63 | zgrep -P "VALUES\s*\(.*\d:\d:...*'" database.sql | awk '{print $3}' | sort | uniq -c 64 | ``` 65 | 66 | 2. Review your `env.php`, if you store any encrypted values there they will need to be reissued by the provider as they may have been leaked. 67 | 2. **Review functions** using `->hash(` from the encryptor class. Changing the keys will result in a different hash. 68 | 3. If you have **custom logic** to handle that, it will be something you need to work that out manually. 69 | 3. **Generate a new key** `php bin/magento gene:encryption-key-manager:generate` 70 | 1. You can specify the new crypt key to use with `php bin/magento gene:encryption-key-manager:generate --key=MY_32_CHAR_CRYPT_KEY` 71 | 2. `Magento\Catalog\Model\View\Asset\Image` will continue to use the key at the `0` index 72 | 3. `Magento\JwtUserToken\Model\SecretBasedJwksFactory` will only use the most recently generated key at the highest index 73 | 4. **Fix missing config values** `php bin/magento gene:encryption-key-manager:reencrypt-unhandled-core-config-data` 74 | 1. Re-run to verify `php bin/magento gene:encryption-key-manager:reencrypt-unhandled-core-config-data` 75 | 4. **Fix 2FA data** `php bin/magento gene:encryption-key-manager:reencrypt-tfa-data` 76 | 1. Re-run to verify `php bin/magento gene:encryption-key-manager:reencrypt-tfa-data` 77 | 5. Fix up all additional identified columns like so, be careful to verify each table and column as this may not be an exhaustive list (also be aware of `entity_id`, `row_id` and `id`) 78 | 1. `php bin/magento gene:encryption-key-manager:reencrypt-column admin_user user_id rp_token` 79 | 2. `php bin/magento gene:encryption-key-manager:reencrypt-column customer_entity entity_id rp_token` 80 | 3. `php bin/magento gene:encryption-key-manager:reencrypt-column oauth_token entity_id secret` 81 | 4. `php bin/magento gene:encryption-key-manager:reencrypt-column oauth_consumer entity_id secret` 82 | 5. `php bin/magento gene:encryption-key-manager:reencrypt-column admin_adobe_ims_webapi id access_token` 83 | 6. `php bin/magento gene:encryption-key-manager:reencrypt-column adobe_user_profile id access_token` 84 | 6. Flush the cache `php bin/magento cache:flush` 85 | 6. At this point you should have all your data migrated to your new encryption key, to help you verify this you can do the following 86 | 1. `php bin/magento config:set --lock-env dev/debug/gene_encryption_manager_only_log_old_decrypts 1` 87 | 2. `php bin/magento config:set --lock-env dev/debug/gene_encryption_manager_enable_decrypt_logging 1` 88 | 3. Monitor your logs for "gene encryption manager" to verify nothing is still using the old key 89 | 7. When you are happy you can **invalidate your old key** `php bin/magento gene:encryption-key-manager:invalidate` 90 | 1. `Magento\Catalog\Model\View\Asset\Image` will continue to use the key at the `0` index in the `crypt/invalidated_key` section 91 | 6. Test, test test! Your areas of focus for testing include 92 | - all integrations that use Magento's APIs 93 | - your media should still be displaying with the same hash directory. If it is regenerating it would take up a large amount of disk space and runtime. 94 | - admin user login/logout 95 | - customer login/logout 96 | 97 | # Features 98 | 99 | ## Automatically invalidates old JWTs when a new key is generated 100 | When magento generates a new encryption key it still allows the old one to be used with JWTs. This module prevents that by updating `\Magento\JwtUserToken\Model\SecretBasedJwksFactory` to only allow keys generated against the most recent encryption key. 101 | 102 | We inject a wrapped `\Gene\EncryptionKeyManager\Model\DeploymentConfig` which only returns the most recent encryption key. This means that any existing tokens are no longer usable when a new encryption key is generated. 103 | 104 | ## Allows you to keep your existing media cache directories 105 | When magento generates a new encryption key, it causes the product media cache hash to change. This causes all product media to regenerate which takes a lot of processing time which can slow down page loads for your customers, as well as consuming extra disk space. This module ensures the old hash is still used for the media gallery. 106 | 107 | Magento stores resized product images in directories like `media/catalog/product/cache/abc123/f/o/foobar.jpg`, the hash `abc123` is generated utilising the encryption keys in the system. 108 | 109 | To avoid having to regenerate all the product media when cycling the encryption key there are some changes to force it to continue using the original value. 110 | 111 | `Magento\Catalog\Model\View\Asset\Image` has the `$encryptor` swapped out with `Gene\EncryptionKeyManager\Service\InvalidatedKeyHasher`. Which allows you to continue to generate md5 hashes with the old key. 112 | 113 | ## Prevents long running process updating order payments 114 | This module will also fix an issue where every `sales_order_payment` entry was updated during the key generation process. On large stores this could take a long time. Now only necessary entries with saved card information are updated. 115 | 116 | ## Logging 117 | 118 | This module provides a mechanism to log the source for each call to decrypt. This will produce a lot of data as the magento system configuration is encrypted so each request will trigger a log write. 119 | 120 | It is recommended to enable this logging after you have handled the re-encryption of all data in your system, but _before_ you invalidate the old keys. This will give you an indication that you have properly handled everything, because if you see a log written it will tell you that something has been missed and where to go to find the source of the issue. 121 | 122 | ```bash 123 | php bin/magento config:set --lock-env dev/debug/gene_encryption_manager_only_log_old_decrypts 1 124 | php bin/magento config:set --lock-env dev/debug/gene_encryption_manager_enable_decrypt_logging 1 125 | ``` 126 | 127 | The log file is located in /var/log/gene_encryption_key.log 128 | 129 | ## bin/magento gene:encryption-key-manager:generate 130 | 131 | You can use `php bin/magento gene:encryption-key-manager:generate` to generate a new encryption key 132 | 133 | This CLI tool does the same tasks as `\Magento\EncryptionKey\Controller\Adminhtml\Crypt\Key\Save::execute()` with a few tweaks 134 | - Avoids unneeded manipulation of empty `sales_order_payment` `cc_number_enc` values. This can he helpful on large stores with many items in this table. 135 | 136 | ```bash 137 | $ php bin/magento gene:encryption-key-manager:generate --force 138 | Generating a new encryption key 139 | _reEncryptSystemConfigurationValues - start 140 | _reEncryptSystemConfigurationValues - end 141 | _reEncryptCreditCardNumbers - start 142 | _reEncryptCreditCardNumbers - end 143 | Cleaning cache 144 | Done 145 | ``` 146 | 147 | - Use the `--key` option to manually define the new key to use during re-encryption. If no custom key is provided, a new key will be generated. 148 | - Use the `--skip-saved-credit-cards` flag to skip re-encrypting the `sales_order_payment` `cc_number_enc` data. This table can be very large, and many stores will have no data saved in this column. 149 | - This will automatically re-encrypt any `system` values in `app/etc/env.php` 150 | 151 | ## bin/magento gene:encryption-key-manager:invalidate 152 | 153 | You can use `php bin/magento gene:encryption-key-manager:invalidate` to invalidate old keys 154 | 155 | This will create a new section to store the old `invalidated_key` within your `env.php` as well as stub out the `crypt/key` path with nonsense text, so that the numerical ordering of the keys is maintained. 156 | 157 | Before invalidation 158 | ```php 159 | 'crypt' => [ 160 | 'key' => '84c9d7c0b305adf9ea7e19a05478bf11 161 | 2951b41e2b7f4c26e60a8e7ee00ca17b' 162 | ], 163 | ``` 164 | 165 | After invalidation 166 | ```php 167 | 'crypt' => [ 168 | 'key' => 'invalidpwecbVeGpoL3Jxa4PXEOdn1ej 169 | 2951b41e2b7f4c26e60a8e7ee00ca17b', 170 | 'invalidated_key' => '84c9d7c0b305adf9ea7e19a05478bf11' 171 | ], 172 | ``` 173 | 174 | ## bin/magento gene:encryption-key-manager:reencrypt-unhandled-core-config-data 175 | 176 | When Magento generates a new encryption key it re-encrypts values in `core_config_data` where the `backend_model` is defined as `Magento\Config\Model\Config\Backend\Encrypted`. It is likely some third party modules have not implemented this correctly and handled the decryption themselves. In these cases we need to force through the re-encryption process for them. 177 | 178 | This command runs in dry run mode by default, do that as a first pass to see the changes that will be made. When you are happy run with `--force`. 179 | 180 | ```bash 181 | $ php bin/magento gene:encryption-key-manager:reencrypt-unhandled-core-config-data 182 | Run with --force to make these changes, this will run in dry-run mode by default 183 | The latest encryption key is number 14, looking for old entries 184 | ################################################################################ 185 | config_id: 1347 186 | scope: default 187 | scope_id: 0 188 | path: yotpo/settings/secret 189 | updated_at: 2023-08-31 12:48:27 190 | ciphertext_old: 0:2:abc123 191 | plaintext: some_secret_here 192 | ciphertext_new: 14:3:xyz456 193 | Dry run mode, no changes have been made 194 | ################################################################################ 195 | Done 196 | ``` 197 | 198 | ## bin/magento gene:encryption-key-manager:reencrypt-column 199 | 200 | This allows you to target a specific column for re-encryption. If the column contains JSON, you can target it using dot notation: `column.field`. 201 | 202 | This command runs in dry run mode by default, do that as a first pass to see the changes that will be made. When you are happy run with `--force`. 203 | 204 | You should identify all columns that need to be handled, and run them through this process. 205 | 206 | ```bash 207 | $ bin/magento gene:encryption-key-manager:reencrypt-column customer_entity entity_id rp_token 208 | Run with --force to make these changes, this will run in dry-run mode by default 209 | The latest encryption key is number 1, looking for old entries 210 | Looking for 'rp_token' in 'customer_entity', identified by 'entity_id' 211 | ######################################################################################################################## 212 | entity_id: 9876 213 | ciphertext_old: 0:3:54+QHWqhSwuncAa87Ueph7xF9qPL1CT6+M9Z5AWuup447J33KGVw+Q+BvVLSKR1H1umiq69phKq5NEHk 214 | plaintext: acb123 215 | ciphertext_new: 1:3:Y52lxB2VDnKeOHa0hf7kG/d15oooib6GQOYTcAmzfuEnhfW64NAdNN4YjRrhlh2IzQBO5IbwS48JDDRh 216 | Dry run mode, no changes have been made 217 | ######################################################################################################################## 218 | Done 219 | ``` 220 | 221 | ## bin/magento gene:encryption-key-manager:reencrypt-tfa-data 222 | 223 | This command re-encrypts the 2FA data stored in `tfa_user_config`. Some of this data is doubly encrypted, which is why it needs special handling. 224 | 225 | This command runs in dry run mode by default, do that as a first pass to see the changes that will be made. When you are happy run with `--force`. 226 | 227 | This CLI has only been tested with Google Authenticator (TOTP) and U2F (Yubikey, etc). If you use Authy or DUO you *MUST* verify before use. 228 | 229 | ```bash 230 | $ bin/magento gene:encryption-key-manager:reencrypt-tfa-data 231 | Run with --force to make these changes, this will run in dry-run mode by default 232 | This CLI has only been tested with Google Authenticator (TOTP) and U2F (Yubikey, etc). If you use Authy or DUO you *MUST* verify before use. 233 | The latest encryption key is number 1, looking for old entries 234 | Looking for encoded_config in tfa_user_config, identified by 'config_id' 235 | ######################################################################################################################## 236 | config_id: 1 237 | ciphertext_old: 0:3:rV/z9+ilmOtaPGnOoZBayZ3waBNphK1RAcyWLetipM5UONn793rTyRknO1GhWxKxXC3ooJAgWDTMJPaXGRMGdj8yOqrlrjEp9uqi8D9SFgE/UTiWkBF4RRwVvZeo4lGGnll/CxJmtzuMXWa65TS0Z/a2QLdPyIH/3OomJH7sb3FgfQ== 238 | plaintext_old: {"google":{"secret":"0:3:LKm9642Rpl0gqlBha+m3FYWnQBBtLgjdLDvjfoPo923xmxd9ykbnvX0LucKI","active":true}} 239 | plaintext_new: {"google":{"secret":"1:3:m9mScDkTkeCdn2lXpwf5oMkL7lmgLOTYJXQyKbK\/m8QwDZVDNWI3CzH+uBaq","active":true}} 240 | ciphertext_new: 1:3:Tw/5ik2meBqzL8oodrudxmksrOekA/DbZE5+KgBAygFxp6Zx/A7vbMyHt4+N1MtQhlnqW/mAXL3l2kDpFHIQVvi2L+23o9mRpii2ldBwmuZgDlpQsm+Q4Hf8a+t2aUKndGOMeoH6xcZXFCConC+TUI+uregFXx6B5LU4ohCY52m/v7w= 241 | Dry run mode, no changes have been made 242 | ######################################################################################################################## 243 | Done 244 | ``` 245 | 246 | ## bin/magento gene:encryption-key-manager:get-cloud-keys 247 | 248 | This command to get re-encrypted cloud environments variables. 249 | This one DOESN'T update existing values, it just returns new ones in console. 250 | The Dev has to update them manually in cloud console. 251 | 252 | ```bash 253 | # No keys example 254 | $ bin/magento gene:encryption-key-manager:get-cloud-keys 255 | There is no old encrypted environment variables found 256 | 257 | # There is some encoded 258 | $ bin/magento gene:encryption-key-manager:get-cloud-keys --show-decrypted 259 | There is no old encrypted environment variables found 260 | The CLI command doesn\'t rewrite values. You have to update them manually in cloud console! 261 | Rows count: 4 262 | ################################################################## 263 | Name: CONFIG__DEFAULT__SOME_KEY 264 | Dectypted value: dectypted_value 265 | Old Encrypted Value: 0:3:AAA1 266 | New Encrypted Value: 1:3:BBB1 267 | ################################################################## 268 | Name: CONFIG__DEFAULT__SOME_KEY_2 269 | Dectypted value: dectypted_value_2 270 | Old Encrypted Value: 0:3:AAA2 271 | New Encrypted Value: 1:3:BBB2 272 | ``` 273 | 274 | # Caveats 275 | 276 | ## report.WARNING: Unable to unserialize value 277 | 278 | This is not common, it has been reported by people using this module and people using the admin controller to rotate their encryption keys. Flushing redis cache resolves the issue. 279 | 280 | Please ensure you [flush your redis cache](https://redis.io/docs/latest/commands/flushall/) 281 | 282 | Now you are right to continue with the re-encryption work as stated above. 283 | 284 | ## You manually replaced your encryption key 285 | You will need to: 286 | 1. Recover your old encryption key 287 | 1. Prepend your old key to the new key, separated by `\n` and repeat the steps above 288 | 289 | ## Other issues 290 | 291 | Search https://github.com/genecommerce/module-encryption-key-manager/issues for other issues. 292 | -------------------------------------------------------------------------------- /Service/ChangeEncryptionKey.php: -------------------------------------------------------------------------------- 1 | output = $output; 23 | } 24 | 25 | /** 26 | * @param bool $skipSavedCreditCards 27 | * @return void 28 | */ 29 | public function setSkipSavedCreditCards($skipSavedCreditCards) 30 | { 31 | $this->skipSavedCreditCards = (bool) $skipSavedCreditCards; 32 | } 33 | 34 | /** 35 | * @param string $text 36 | * @return void 37 | */ 38 | private function writeOutput($text) 39 | { 40 | if ($this->output instanceof OutputInterface) { 41 | $this->output->writeln($text); 42 | } 43 | } 44 | 45 | /** 46 | * Gather all encrypted system config values and re-encrypt them 47 | * 48 | * @return void 49 | */ 50 | protected function _reEncryptSystemConfigurationValues() 51 | { 52 | $this->writeOutput('_reEncryptSystemConfigurationValues - start'); 53 | parent::_reEncryptSystemConfigurationValues(); 54 | $this->writeOutput('_reEncryptSystemConfigurationValues - end'); 55 | } 56 | 57 | /** 58 | * Gather saved credit card numbers from sales order payments and re-encrypt them 59 | * 60 | * The parent function does not handle null values, so this version filters them out as well as adding CLI output 61 | * 62 | * @return void 63 | */ 64 | protected function _reEncryptCreditCardNumbers() 65 | { 66 | if ($this->skipSavedCreditCards) { 67 | $this->writeOutput('_reEncryptCreditCardNumbers - skipping'); 68 | return; 69 | } 70 | $this->writeOutput('_reEncryptCreditCardNumbers - start'); 71 | $table = $this->getTable('sales_order_payment'); 72 | $select = $this->getConnection()->select()->from($table, ['entity_id', 'cc_number_enc']); 73 | 74 | $attributeValues = $this->getConnection()->fetchPairs($select); 75 | // save new values 76 | foreach ($attributeValues as $valueId => $value) { 77 | // GENE CHANGE START 78 | if (!$value) { 79 | continue; 80 | } 81 | // GENE CHANGE END 82 | $this->getConnection()->update( 83 | $table, 84 | ['cc_number_enc' => $this->encryptor->encrypt($this->encryptor->decrypt($value))], 85 | ['entity_id = ?' => (int)$valueId] 86 | ); 87 | } 88 | $this->writeOutput('_reEncryptCreditCardNumbers - end'); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Service/InvalidatedKeyHasher.php: -------------------------------------------------------------------------------- 1 | scopeConfig->getValue('gene/encryption_key_manager/invalidated_key_index'); 34 | if ($keyIndex === null) { 35 | return; 36 | } 37 | $this->keyVersion = (int) $keyIndex; 38 | 39 | $invalidatedKeys = array_filter(preg_split('/\s+/s', trim((string)$deploymentConfig->get('crypt/invalidated_key')))); 40 | if (!empty($invalidatedKeys)) { 41 | $this->keys = $invalidatedKeys; 42 | } 43 | } 44 | 45 | /** 46 | * @throws \LogicException 47 | */ 48 | private function fail() 49 | { 50 | throw new \LogicException('You can only use this class for the "hash" function with invalidated keys'); 51 | } 52 | 53 | /** 54 | * @inheritdoc 55 | * @throws \LogicException 56 | */ 57 | public function getHash($password, $salt = false, $version = self::HASH_VERSION_LATEST) 58 | { 59 | return $this->fail(); 60 | } 61 | 62 | /** 63 | * @inheritdoc 64 | * @throws \LogicException 65 | */ 66 | public function isValidHash($password, $hash) 67 | { 68 | return $this->fail(); 69 | } 70 | 71 | /** 72 | * @inheritdoc 73 | * @throws \LogicException 74 | */ 75 | public function validateHashVersion($hash, $validateCount = false) 76 | { 77 | return $this->fail(); 78 | } 79 | 80 | /** 81 | * @inheritdoc 82 | * @throws \LogicException 83 | */ 84 | public function encrypt($data) 85 | { 86 | return $this->fail(); 87 | } 88 | 89 | /** 90 | * @inheritdoc 91 | * @throws \LogicException 92 | */ 93 | public function decrypt($data) 94 | { 95 | return $this->fail(); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /Service/ReencryptEnvSystemConfigurationValues.php: -------------------------------------------------------------------------------- 1 | deploymentConfig->resetData(); 47 | $this->encryptor = $this->encryptorFactory->create(); 48 | $systemConfig = $this->deploymentConfig->get('system'); 49 | if (!$systemConfig) { 50 | return; 51 | } 52 | $systemConfig = $this->iterateSystemConfig($systemConfig); 53 | 54 | $encryptSegment = new ConfigData(ConfigFilePool::APP_ENV); 55 | $encryptSegment->set('system', $systemConfig); 56 | $this->writer->saveConfig([$encryptSegment->getFileKey() => $encryptSegment->getData()]); 57 | 58 | /** 59 | * @see \Magento\Deploy\Console\Command\App\ConfigImport\Processor::execute() 60 | */ 61 | $this->hash->regenerate('system'); 62 | } 63 | 64 | /** 65 | * Recursively iterate through the system configuration and re-encrypt any encrypted values 66 | * 67 | * @param array $systemConfig 68 | * @return array 69 | * @throws \Exception 70 | */ 71 | private function iterateSystemConfig(array $systemConfig): array 72 | { 73 | foreach ($systemConfig as $key => &$value) { 74 | if (is_array($value)) { 75 | $value = $this->iterateSystemConfig($value); 76 | } elseif (is_string($value) && preg_match('/^\d+:\d+:.*$/', $value)) { 77 | $decryptedValue = $this->encryptor->decrypt($value); 78 | if ($decryptedValue) { 79 | $value = $this->encryptor->encrypt($decryptedValue); 80 | } 81 | } 82 | } 83 | 84 | return $systemConfig; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gene/module-encryption-key-manager", 3 | "description": "Gene encryption key manager", 4 | "type": "magento2-module", 5 | "license": "LGPL-3.0-only", 6 | "require": { 7 | "php": "^8.1||^8.2||^8.3|^8.4", 8 | "magento/module-jwt-user-token": "*", 9 | "magento/module-encryption-key": "*", 10 | "magento/module-catalog": "*" 11 | }, 12 | "autoload": { 13 | "files": [ 14 | "registration.php" 15 | ], 16 | "psr-4": { 17 | "Gene\\EncryptionKeyManager\\": "" 18 | } 19 | }, 20 | "minimum-stability": "dev", 21 | "prefer-stable": true 22 | } 23 | -------------------------------------------------------------------------------- /dev/README.md: -------------------------------------------------------------------------------- 1 | These tests are not what we would like long term, but for a quick win they have been added as a shell script. 2 | 3 | To run the tests 4 | ```bash 5 | # Get set up with the module 6 | git clone https://github.com/genecommerce/module-encryption-key-manager 7 | git clone https://github.com/AmpersandHQ/magento-docker-test-instance --branch 0.1.21 8 | cd magento-docker-test-instance 9 | 10 | # Install magento 11 | CURRENT_EXTENSION="../module-encryption-key-manager" FULL_INSTALL=1 ./bin/mtest-make 2-4-6-p3 12 | 13 | # Setup tests 14 | ./bin/mtest 'cp vendor/gene/module-encryption-key-manager/dev/test.sh .' 15 | ./bin/mtest 'chmod +x ./test.sh' 16 | 17 | # Run tests 18 | ./bin/mtest './test.sh' 19 | ``` 20 | -------------------------------------------------------------------------------- /dev/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | err_report() { 4 | echo "Error on line $1" 5 | echo "last test.txt was" 6 | cat test.txt 7 | echo "last app/etc/env.php was" 8 | cat app/etc/env.php 9 | echo "restoring original env.php" 10 | cp app/etc/env.php.bak app/etc/env.php 11 | } 12 | trap 'err_report $LINENO' ERR 13 | echo "backing up initial app/etc/env.php" 14 | rm -rf var/cache 15 | cp app/etc/env.php app/etc/env.php.bak 16 | php bin/magento app:config:import 17 | 18 | URL='http://0.0.0.0:1234/' 19 | CURRENT_TIMESTAMP=$(date +%s) 20 | ADMIN="adminuser$CURRENT_TIMESTAMP" 21 | PASSWORD='password123' 22 | 23 | echo "Putting magento into production mode, di:compile has already been generated" 24 | php bin/magento deploy:mode:set production -s 25 | php -d memory_limit=-1 bin/magento setup:static-content:deploy en_US --no-interaction -f --no-ansi --area=frontend 26 | 27 | echo "Verifying frontend is functional" 28 | php bin/magento cache:disable full_page 29 | curl "$URL" -vvv > test.txt 2>&1 30 | grep -q '200 OK' test.txt 31 | grep --max-count=1 'static/version' test.txt 32 | grep -q 'All rights reserved.' test.txt 33 | echo "PASS" 34 | echo "";echo ""; 35 | 36 | echo "Stubbing in some test data" 37 | vendor/bin/n98-magerun2 --version 38 | vendor/bin/n98-magerun2 admin:user:create --no-interaction --admin-user "$ADMIN" --admin-email "example$CURRENT_TIMESTAMP@example.com" --admin-password $PASSWORD --admin-firstname adminuser --admin-lastname adminuser 39 | vendor/bin/n98-magerun2 config:store:set zzzzz/zzzzz/zzzz xyz123 --encrypt 40 | 41 | echo "Spoofing an encrypted config value into env.php" 42 | ENCRYPTED_ENV_VALUE=$(vendor/bin/n98-magerun2 dev:encrypt 'Some Base Name') 43 | bin/magento config:set --lock-env general/store_information/name "$ENCRYPTED_ENV_VALUE" 44 | 45 | ADMIN_ID=$(vendor/bin/n98-magerun2 db:query "SELECT user_id FROM admin_user LIMIT 1") 46 | ADMIN_ID="${ADMIN_ID: -1}" 47 | FAKE_GOOGLE_TOKEN=$(vendor/bin/n98-magerun2 dev:encrypt 'googletokenabc123') 48 | TWOFA_JSON="{\"google\":{\"secret\":\"$FAKE_GOOGLE_TOKEN\",\"active\":true}}" 49 | TWOFA_JSON_ENCRYPTED=$(vendor/bin/n98-magerun2 dev:encrypt "$TWOFA_JSON") 50 | echo "Generating 2FA data for admin user $ADMIN in tfa_user_config" 51 | vendor/bin/n98-magerun2 db:query "delete from tfa_user_config where user_id=$ADMIN_ID"; 52 | vendor/bin/n98-magerun2 db:query "insert into tfa_user_config(user_id, encoded_config) values ($ADMIN_ID, '$TWOFA_JSON_ENCRYPTED');" 53 | vendor/bin/n98-magerun2 db:query "select user_id, encoded_config from tfa_user_config where user_id=$ADMIN_ID"; 54 | 55 | FAKE_RP_TOKEN=$(vendor/bin/n98-magerun2 dev:encrypt 'abc123') 56 | vendor/bin/n98-magerun2 db:query "update admin_user set rp_token='$FAKE_RP_TOKEN' where username='$ADMIN'" 57 | echo "Generated FAKE_RP_TOKEN=$FAKE_RP_TOKEN and assigned to $ADMIN" 58 | 59 | echo "Generating a fake json column" 60 | FAKE_JSON_PASSWORD=$(vendor/bin/n98-magerun2 dev:encrypt 'jsonpasswordabc123') 61 | FAKE_JSON_USERNAME=$(vendor/bin/n98-magerun2 dev:encrypt 'foobar') 62 | FAKE_JSON_PAYLOAD="{\"username\": \"$FAKE_JSON_USERNAME\", \"password\": \"$FAKE_JSON_PASSWORD\", \"request_url\": \"\"}" 63 | vendor/bin/n98-magerun2 db:query 'DROP TABLE IF EXISTS fake_json_table; CREATE TABLE fake_json_table (id INT AUTO_INCREMENT PRIMARY KEY, text_column TEXT);' 64 | vendor/bin/n98-magerun2 db:query "insert into fake_json_table(text_column) values ('$FAKE_JSON_PAYLOAD');" 65 | vendor/bin/n98-magerun2 db:query "select * from fake_json_table"; 66 | 67 | echo "";echo ""; 68 | 69 | echo "Verifying commands need to use --force" 70 | 71 | php bin/magento gene:encryption-key-manager:generate > test.txt || true; 72 | if grep -q 'Run with --force' test.txt; then 73 | echo "PASS: generate needs to run with force" 74 | else 75 | echo "FAIL: generate needs to run with force" && false 76 | fi 77 | 78 | php bin/magento gene:encryption-key-manager:invalidate > test.txt || true 79 | if grep -q 'Run with --force' test.txt; then 80 | echo "PASS: invalidate needs to run with force" 81 | else 82 | echo "FAIL: invalidate needs to run with force" && false 83 | fi 84 | 85 | php bin/magento gene:encryption-key-manager:reencrypt-unhandled-core-config-data > test.txt || true 86 | if grep -q 'Run with --force' test.txt; then 87 | echo "PASS: reencrypt-unhandled-core-config-data needs to run with force" 88 | else 89 | echo "FAIL: reencrypt-unhandled-core-config-data needs to run with force" && false 90 | fi 91 | 92 | php bin/magento gene:encryption-key-manager:reencrypt-tfa-data > test.txt || true 93 | if grep -q 'Run with --force' test.txt; then 94 | echo "PASS: reencrypt-tfa-data needs to run with force" 95 | else 96 | echo "FAIL: reencrypt-tfa-data needs to run with force" && false 97 | fi 98 | 99 | php bin/magento gene:encryption-key-manager:reencrypt-column admin_user user_id rp_token > test.txt || true 100 | if grep -q 'Run with --force' test.txt; then 101 | echo "PASS: reencrypt-column needs to run with force" 102 | else 103 | echo "FAIL: reencrypt-column needs to run with force" && false 104 | fi 105 | echo "";echo ""; 106 | 107 | echo "Verifying you cannot invalidate with only 1 key" 108 | php bin/magento gene:encryption-key-manager:invalidate --force > test.txt || true 109 | if grep -Eq 'Cannot invalidate when there is only one key|No further keys need invalidated' test.txt; then 110 | echo "PASS: You cannot invalidate with only 1 key" 111 | else 112 | echo "FAIL" && false 113 | fi 114 | echo "";echo ""; 115 | 116 | echo "Generating a new encryption key" 117 | grep -q "$ENCRYPTED_ENV_VALUE" app/etc/env.php 118 | php bin/magento gene:encryption-key-manager:generate --force > test.txt 119 | if grep -q "$ENCRYPTED_ENV_VALUE" app/etc/env.php; then 120 | echo "FAIL: The old encrypted value in env.php was not updated" && false 121 | fi 122 | grep "'name'" app/etc/env.php | grep -q "1:3:" 123 | grep -q '_reEncryptSystemConfigurationValues - start' test.txt 124 | grep -q '_reEncryptSystemConfigurationValues - end' test.txt 125 | grep -q '_reEncryptCreditCardNumbers - start' test.txt 126 | grep -q '_reEncryptCreditCardNumbers - end' test.txt 127 | echo "PASS" 128 | echo "";echo ""; 129 | 130 | echo "Generating a new encryption key - skipping _reEncryptCreditCardNumbers" 131 | php bin/magento gene:encryption-key-manager:generate --force --skip-saved-credit-cards > test.txt 132 | grep "'name'" app/etc/env.php | grep -q "2:3:" 133 | grep -q '_reEncryptSystemConfigurationValues - start' test.txt 134 | grep -q '_reEncryptSystemConfigurationValues - end' test.txt 135 | grep -q '_reEncryptCreditCardNumbers - skipping' test.txt 136 | if grep -q '_reEncryptCreditCardNumbers - start' test.txt; then 137 | echo "FAIL: We should never start on _reEncryptCreditCardNumbers with --skip-saved-credit-cards" && false 138 | fi 139 | if grep -q '_reEncryptCreditCardNumbers - end' test.txt; then 140 | echo "FAIL: We should never end on _reEncryptCreditCardNumbers with --skip-saved-credit-cards" && false 141 | fi 142 | echo "PASS" 143 | echo "";echo ""; 144 | 145 | echo "Verifying frontend is still functional after key generation" 146 | php bin/magento cache:flush 147 | curl "$URL?test1" -vvv > test.txt 2>&1 148 | grep -q '200 OK' test.txt 149 | grep --max-count=1 'static/version' test.txt 150 | grep -q 'All rights reserved.' test.txt 151 | echo "PASS" 152 | echo "";echo ""; 153 | 154 | echo "Running reencrypt-unhandled-core-config-data" 155 | php bin/magento gene:encryption-key-manager:reencrypt-unhandled-core-config-data --force > test.txt 156 | cat test.txt 157 | grep -q 'zzzzz/zzzzz/zzzz' test.txt 158 | grep -q 'xyz123' test.txt 159 | echo "PASS" 160 | echo "";echo ""; 161 | echo "Running reencrypt-unhandled-core-config-data - again to verify it was all processed" 162 | php bin/magento gene:encryption-key-manager:reencrypt-unhandled-core-config-data --force | grep --context 999 'No old entries found' 163 | echo "PASS" 164 | echo "";echo ""; 165 | 166 | echo "Running reencrypt-tfa-data" 167 | php bin/magento gene:encryption-key-manager:reencrypt-tfa-data --force > test.txt 168 | cat test.txt 169 | grep 'plaintext_new' test.txt | grep 'secret' test.txt 170 | if grep 'plaintext_new' test.txt | grep "$TWOFA_JSON_ENCRYPTED"; then 171 | echo "FAIL: The plaintext_new should no longer have the original TWOFA_JSON_ENCRYPTED data" && false 172 | else 173 | echo "PASS: The plaintext_new should no longer have the original TWOFA_JSON_ENCRYPTED data" 174 | fi 175 | echo "PASS" 176 | echo "";echo ""; 177 | echo "Running reencrypt-tfa-data - again to verify it was all processed" 178 | php bin/magento gene:encryption-key-manager:reencrypt-tfa-data --force | grep --context 999 'No old entries found' 179 | echo "PASS" 180 | echo "";echo ""; 181 | 182 | echo "Running reencrypt-column" 183 | php bin/magento gene:encryption-key-manager:reencrypt-column admin_user user_id rp_token --force > test.txt 184 | cat test.txt 185 | grep -q "$FAKE_RP_TOKEN" test.txt 186 | grep -q abc123 test.txt 187 | echo "PASS" 188 | echo "";echo ""; 189 | echo "Running reencrypt-column - again to verify it was all processed" 190 | php bin/magento gene:encryption-key-manager:reencrypt-column admin_user user_id rp_token --force | grep --context 999 'No old entries found' 191 | echo "PASS" 192 | echo "";echo ""; 193 | 194 | echo "Running reencrypt-column on JSON column username" 195 | php bin/magento gene:encryption-key-manager:reencrypt-column fake_json_table id text_column.username --force > test.txt 196 | cat test.txt 197 | grep -q "$FAKE_JSON_USERNAME" test.txt 198 | grep -q foobar test.txt 199 | echo "PASS" 200 | echo "";echo ""; 201 | echo "Running reencrypt-column on JSON column username - again to verify it was all processed" 202 | php bin/magento gene:encryption-key-manager:reencrypt-column fake_json_table id text_column.username --force | grep --context 999 'No old entries found' 203 | echo "PASS" 204 | echo "";echo ""; 205 | 206 | echo "Running reencrypt-column on JSON column password to validate multiple fields can be re-encrypted" 207 | php bin/magento gene:encryption-key-manager:reencrypt-column fake_json_table id text_column.password --force > test.txt 208 | cat test.txt 209 | grep -q "$FAKE_JSON_PASSWORD" test.txt 210 | grep -q jsonpasswordabc123 test.txt 211 | echo "PASS" 212 | echo "";echo ""; 213 | echo "Running reencrypt-column on JSON column password to validate multiple fields can be re-encrypted - again to verify it was all processed" 214 | php bin/magento gene:encryption-key-manager:reencrypt-column fake_json_table id text_column.password --force | grep --context 999 'No old entries found' 215 | echo "PASS" 216 | echo "";echo ""; 217 | 218 | echo "Running invalidate" 219 | php bin/magento gene:encryption-key-manager:invalidate --force 220 | grep -q invalidated_key app/etc/env.php 221 | php bin/magento gene:encryption-key-manager:invalidate --force | grep --context 999 'No further keys need invalidated' 222 | echo "PASS" 223 | echo "";echo ""; 224 | 225 | echo "Testing the decrypt logger" 226 | 227 | echo "Testing disable behaviour" 228 | php bin/magento config:set --lock-env dev/debug/gene_encryption_manager_enable_decrypt_logging 0 229 | php bin/magento config:set --lock-env dev/debug/gene_encryption_manager_only_log_old_decrypts 0 230 | php bin/magento cache:flush; php bin/magento | head -1; # clear and warm caches 231 | rm -f var/log/*.log && php bin/magento | head -1 # trigger a decrypt of the stored system config 232 | touch var/log/gene_encryption_key.log 233 | ls -l var/log 234 | if grep -q 'gene encryption manager' var/log/gene_encryption_key.log; then 235 | cat var/log/gene_encryption_key.log 236 | echo "FAIL: No logs should be produced without enabling the logger" && false 237 | else 238 | echo "PASS: No logs were produced" 239 | fi 240 | echo "";echo ""; 241 | 242 | echo "Testing that enabling it produces a log" 243 | php bin/magento config:set --lock-env dev/debug/gene_encryption_manager_enable_decrypt_logging 1 244 | php bin/magento config:set --lock-env dev/debug/gene_encryption_manager_only_log_old_decrypts 0 245 | php bin/magento cache:flush; php bin/magento | head -1; # clear and warm caches 246 | rm -f var/log/*.log && php bin/magento | head -1 # trigger a decrypt of the stored system config 247 | touch var/log/gene_encryption_key.log 248 | ls -l var/log 249 | if grep 'gene encryption manager' var/log/gene_encryption_key.log | grep -q 'Magento\\'; then 250 | echo "PASS: A log was produced" 251 | else 252 | cat var/log/gene_encryption_key.log 253 | echo "FAIL: A log should be produced" && false 254 | fi 255 | echo "";echo ""; 256 | 257 | echo "Testing that gene_encryption_manager_only_log_old_decrypts=1 stops a log being written" 258 | php bin/magento config:set --lock-env dev/debug/gene_encryption_manager_only_log_old_decrypts 1 259 | php bin/magento cache:flush; php bin/magento | head -1; # clear and warm caches 260 | rm -f var/log/*.log && php bin/magento | head -1 # trigger a decrypt of the stored system config 261 | touch var/log/gene_encryption_key.log 262 | ls -l var/log 263 | if grep -q 'gene encryption manager' var/log/gene_encryption_key.log; then 264 | cat var/log/gene_encryption_key.log 265 | echo "FAIL: No logs should be produced when the keys are up to date" && false 266 | else 267 | echo "PASS: No logs were produced when the keys were up to date" 268 | fi 269 | echo "";echo ""; 270 | 271 | echo "Testing that gene_encryption_manager_only_log_old_decrypts=1 writes when an old key is used" 272 | rm -f var/log/*.log && php bin/magento | head -1 # trigger a decrypt of the stored system config 273 | vendor/bin/n98-magerun2 dev:decrypt '0:3:qwertyuiopasdfghjklzxcvbnm' # we are on a higher key than 0 now 274 | touch var/log/gene_encryption_key.log 275 | ls -l var/log 276 | if grep 'gene encryption manager' var/log/gene_encryption_key.log | grep -q 'DecryptCommand'; then 277 | echo "PASS: We have a log hit when trying to decrypt with the old key" 278 | else 279 | cat var/log/gene_encryption_key.log 280 | echo "FAIL: We should have a log hit when trying to decrypt using an old key" && false 281 | fi 282 | echo "";echo ""; 283 | 284 | echo "Verifying that the log is not present in system.log" 285 | touch var/log/system.log 286 | if grep 'gene encryption manager' var/log/system.log | grep -q 'DecryptCommand'; then 287 | cat var/log/system.log 288 | echo "FAIL: The log is also present in system.log" && false 289 | else 290 | echo "PASS: The log is not present in system.log" 291 | fi 292 | 293 | echo "Verifying frontend is still functional after all the tests" 294 | php bin/magento cache:flush 295 | curl "$URL?test2" -vvv > test.txt 2>&1 296 | grep -q '200 OK' test.txt 297 | grep --max-count=1 'static/version' test.txt 298 | grep -q 'All rights reserved.' test.txt 299 | echo "PASS" 300 | echo "";echo ""; 301 | 302 | echo "A peek at an example log" 303 | grep 'gene encryption manager' var/log/gene_encryption_key.log | tail -1 304 | 305 | echo "A peek at the env.php" 306 | grep "'name'" app/etc/env.php 307 | grep -A10 "'crypt' =>" app/etc/env.php 308 | echo "";echo ""; 309 | 310 | echo "restoring original env.php" 311 | cp app/etc/env.php.bak app/etc/env.php 312 | echo "DONE" 313 | -------------------------------------------------------------------------------- /etc/adminhtml/system.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | 7 | 8 | Magento\Config\Model\Config\Source\Yesno 9 | 10 | 11 | 12 | Magento\Config\Model\Config\Source\Yesno 13 | 14 | 15 |
16 |
17 |
18 | -------------------------------------------------------------------------------- /etc/di.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Gene\EncryptionKeyManager\Model\DeploymentConfig 11 | 12 | 13 | 14 | 15 | 16 | Gene\EncryptionKeyManager\Service\InvalidatedKeyHasher 17 | 18 | 19 | 20 | 21 | 22 | Gene\EncryptionKeyManager\Service\ChangeEncryptionKey\Proxy 23 | 24 | 25 | 26 | 27 | 28 | Magento\Config\Model\Config\StructureLazy 29 | 30 | 31 | 32 | 33 | 34 | 35 | Gene\EncryptionKeyManager\Console\GenerateEncryptionKey 36 | Gene\EncryptionKeyManager\Console\InvalidateOldEncryptionKeys 37 | Gene\EncryptionKeyManager\Console\ReencryptUnhandledCoreConfigData 38 | Gene\EncryptionKeyManager\Console\ReencryptColumn 39 | Gene\EncryptionKeyManager\Console\ReencryptTfaData 40 | Gene\EncryptionKeyManager\Console\GetReEncryptedCloudEnvironmentsKeys 42 | 43 | 44 | 45 | 46 | 47 | 49 | 50 | /var/log/gene_encryption_key.log 51 | 52 | 53 | 54 | 55 | 56 | GeneEncryptionHandler 57 | 58 | 59 | 60 | 61 | 62 | GeneEncryptionLogger 63 | 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /etc/module.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /registration.php: -------------------------------------------------------------------------------- 1 |