Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.16% covered (warning)
81.16%
56 / 69
50.00% covered (danger)
50.00%
3 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
SwitchDbHashCommand
81.16% covered (warning)
81.16%
56 / 69
50.00% covered (danger)
50.00%
3 / 6
27.85
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 configure
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 getConfigWriter
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getOpenSsl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 fixEntity
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 execute
78.85% covered (warning)
78.85%
41 / 52
0.00% covered (danger)
0.00%
0 / 1
17.13
1<?php
2
3/**
4 * Console command: switch database encryption algorithm.
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2020.
9 *
10 * This program is free software; you can redistribute it and/or modify
11 * it under the terms of the GNU General Public License version 2,
12 * as published by the Free Software Foundation.
13 *
14 * This program is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17 * GNU General Public License for more details.
18 *
19 * You should have received a copy of the GNU General Public License
20 * along with this program; if not, write to the Free Software
21 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
22 *
23 * @category VuFind
24 * @package  Console
25 * @author   Demian Katz <demian.katz@villanova.edu>
26 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
27 * @link     https://vufind.org/wiki/development Wiki
28 */
29
30namespace VuFindConsole\Command\Util;
31
32use Laminas\Config\Config;
33use Laminas\Crypt\BlockCipher;
34use Laminas\Crypt\Exception\InvalidArgumentException;
35use Laminas\Crypt\Symmetric\Openssl;
36use Symfony\Component\Console\Attribute\AsCommand;
37use Symfony\Component\Console\Command\Command;
38use Symfony\Component\Console\Input\InputArgument;
39use Symfony\Component\Console\Input\InputInterface;
40use Symfony\Component\Console\Output\OutputInterface;
41use VuFind\Config\Locator as ConfigLocator;
42use VuFind\Config\PathResolver;
43use VuFind\Config\Writer as ConfigWriter;
44use VuFind\Db\Entity\UserCardEntityInterface;
45use VuFind\Db\Entity\UserEntityInterface;
46use VuFind\Db\Service\DbServiceInterface;
47use VuFind\Db\Service\UserCardServiceInterface;
48use VuFind\Db\Service\UserServiceInterface;
49
50use function count;
51
52/**
53 * Console command: switch database encryption algorithm.
54 *
55 * @category VuFind
56 * @package  Console
57 * @author   Demian Katz <demian.katz@villanova.edu>
58 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
59 * @link     https://vufind.org/wiki/development Wiki
60 */
61#[AsCommand(
62    name: 'util/switch_db_hash',
63    description: 'Encryption algorithm switcher'
64)]
65class SwitchDbHashCommand extends Command
66{
67    /**
68     * Constructor
69     *
70     * @param Config                   $config          VuFind configuration
71     * @param UserServiceInterface     $userService     User database service
72     * @param UserCardServiceInterface $userCardService UserCard database service
73     * @param ?string                  $name            The name of the command; passing null means
74     * it must be set in configure()
75     * @param ?PathResolver            $pathResolver    Config file path resolver
76     */
77    public function __construct(
78        protected Config $config,
79        protected UserServiceInterface $userService,
80        protected UserCardServiceInterface $userCardService,
81        ?string $name = null,
82        protected ?PathResolver $pathResolver = null
83    ) {
84        parent::__construct($name);
85    }
86
87    /**
88     * Configure the command.
89     *
90     * @return void
91     */
92    protected function configure()
93    {
94        $this
95            ->setHelp(
96                'Switches the encryption algorithm in the database '
97                . 'and config. Expects new algorithm and (optional) new key as'
98                . ' parameters.'
99            )->addArgument('newmethod', InputArgument::REQUIRED, 'Encryption method')
100            ->addArgument('newkey', InputArgument::OPTIONAL, 'Encryption key');
101    }
102
103    /**
104     * Get a config writer
105     *
106     * @param string $path Path of file to write
107     *
108     * @return ConfigWriter
109     */
110    protected function getConfigWriter($path)
111    {
112        return new ConfigWriter($path);
113    }
114
115    /**
116     * Get an OpenSsl object for the specified algorithm (or return null if the
117     * algorithm is 'none').
118     *
119     * @param string $algorithm Encryption algorithm
120     *
121     * @return Openssl
122     */
123    protected function getOpenSsl($algorithm)
124    {
125        return ($algorithm == 'none') ? null : new Openssl(compact('algorithm'));
126    }
127
128    /**
129     * Re-encrypt an entity.
130     *
131     * @param AbstractDbService                           $service   Database service
132     * @param UserEntityInterface|UserCardEntityInterface $entity    Row to update
133     * @param ?BlockCipher                                $oldcipher Old cipher (null for none)
134     * @param BlockCipher                                 $newcipher New cipher
135     *
136     * @return void
137     * @throws InvalidArgumentException
138     */
139    protected function fixEntity(
140        DbServiceInterface $service,
141        UserEntityInterface|UserCardEntityInterface $entity,
142        ?BlockCipher $oldcipher,
143        BlockCipher $newcipher
144    ): void {
145        $oldEncrypted = $entity->getCatPassEnc();
146        $pass = ($oldcipher && $oldEncrypted !== null)
147            ? $oldcipher->decrypt($oldEncrypted)
148            : $entity->getRawCatPassword();
149        $entity->setRawCatPassword(null);
150        $entity->setCatPassEnc($pass === null ? null : $newcipher->encrypt($pass));
151        $service->persistEntity($entity);
152    }
153
154    /**
155     * Run the command.
156     *
157     * @param InputInterface  $input  Input object
158     * @param OutputInterface $output Output object
159     *
160     * @return int 0 for success
161     */
162    protected function execute(InputInterface $input, OutputInterface $output)
163    {
164        // Validate command line arguments:
165        $newhash = $input->getArgument('newmethod');
166
167        // Pull existing encryption settings from the configuration:
168        if (
169            !isset($this->config->Authentication->ils_encryption_key)
170            || !($this->config->Authentication->encrypt_ils_password ?? false)
171        ) {
172            $oldhash = 'none';
173            $oldkey = null;
174        } else {
175            $oldhash = $this->config->Authentication->ils_encryption_algo
176                ?? 'blowfish';
177            $oldkey = $this->config->Authentication->ils_encryption_key;
178        }
179
180        // Pull new encryption settings from argument or config:
181        $newkey = $input->getArgument('newkey') ?? $oldkey;
182
183        // No key specified AND no key on file = fatal error:
184        if ($newkey === null) {
185            $output->writeln('Please specify a key as the second parameter.');
186            return 1;
187        }
188
189        // If no changes were requested, abort early:
190        if ($oldkey == $newkey && $oldhash == $newhash) {
191            $output->writeln('No changes requested -- no action needed.');
192            return 0;
193        }
194
195        // Initialize Openssl first, so we can catch any illegal algorithms before
196        // making any changes:
197        try {
198            $oldCrypt = $this->getOpenSsl($oldhash);
199            $newCrypt = $this->getOpenSsl($newhash);
200        } catch (\Exception $e) {
201            $output->writeln($e->getMessage());
202            return 1;
203        }
204
205        // Next update the config file, so if we are unable to write the file,
206        // we don't go ahead and make unwanted changes to the database:
207        $configPath = $this->pathResolver
208            ? $this->pathResolver->getLocalConfigPath('config.ini', null, true)
209            : ConfigLocator::getLocalConfigPath('config.ini', null, true);
210        $output->writeln("\tUpdating $configPath...");
211        $writer = $this->getConfigWriter($configPath);
212        $writer->set('Authentication', 'encrypt_ils_password', true);
213        $writer->set('Authentication', 'ils_encryption_algo', $newhash);
214        $writer->set('Authentication', 'ils_encryption_key', $newkey);
215        if (!$writer->save()) {
216            $output->writeln("\tWrite failed!");
217            return 1;
218        }
219
220        // Set up ciphers for use below:
221        if ($oldhash != 'none') {
222            $oldcipher = new BlockCipher($oldCrypt);
223            $oldcipher->setKey($oldkey);
224        } else {
225            $oldcipher = null;
226        }
227        $newcipher = new BlockCipher($newCrypt);
228        $newcipher->setKey($newkey);
229
230        // Now do the database rewrite:
231        $users = $this->userService->getAllUsersWithCatUsernames();
232        $cards = $this->userCardService->getAllRowsWithUsernames();
233        $output->writeln("\tConverting hashes for " . count($users) . ' user(s).');
234        foreach ($users as $row) {
235            try {
236                $this->fixEntity($this->userService, $row, $oldcipher, $newcipher);
237            } catch (\Exception $e) {
238                $output->writeln("Problem with user {$row->getUsername()}" . (string)$e);
239            }
240        }
241        if (count($cards) > 0) {
242            $output->writeln("\tConverting hashes for " . count($cards) . ' card(s).');
243            foreach ($cards as $entity) {
244                try {
245                    $this->fixEntity($this->userCardService, $entity, $oldcipher, $newcipher);
246                } catch (\Exception $e) {
247                    $output->writeln("Problem with card {$entity->getId()}" . (string)$e);
248                }
249            }
250        }
251
252        // If we got this far, all went well!
253        $output->writeln("\tFinished.");
254        return 0;
255    }
256}