1#! --PERL-- 2# -*- indent-tabs-mode: nil; -*- 3# vim:ft=perl:et:sw=4 4# $Id$ 5 6# Sympa - SYsteme de Multi-Postage Automatique 7# 8# Copyright (c) 1997, 1998, 1999 Institut Pasteur & Christophe Wolfhugel 9# Copyright (c) 1997, 1998, 1999, 2000, 2001, 2002, 2003, 2004, 2005, 10# 2006, 2007, 2008, 2009, 2010, 2011 Comite Reseau des Universites 11# Copyright (c) 2011, 2012, 2013, 2014, 2015, 2016, 2017 GIP RENATER 12# Copyright 2017, 2018, 2019 The Sympa Community. See the AUTHORS.md file at 13# the top-level directory of this distribution and at 14# <https://github.com/sympa-community/sympa.git>. 15# 16# This program is free software; you can redistribute it and/or modify 17# it under the terms of the GNU General Public License as published by 18# the Free Software Foundation; either version 2 of the License, or 19# (at your option) any later version. 20# 21# This program is distributed in the hope that it will be useful, 22# but WITHOUT ANY WARRANTY; without even the implied warranty of 23# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 24# GNU General Public License for more details. 25# 26# You should have received a copy of the GNU General Public License 27# along with this program. If not, see <http://www.gnu.org/licenses/>. 28 29use lib split(/:/, $ENV{SYMPALIB} || ''), '--modulesdir--'; 30use strict; 31use warnings; 32use Digest::MD5; 33use English qw(-no_match_vars); 34use Getopt::Long; 35use MIME::Base64 qw(); 36use Time::HiRes qw(gettimeofday tv_interval); 37 38BEGIN { eval 'use Crypt::CipherSaber'; } 39 40use Conf; 41use Sympa::DatabaseManager; 42use Sympa::User; 43 44my $usage = 45 "Usage: $0 [--dry_run|n] [--debug|d] [--verbose|v] [--config file] [--cache file] [--nosavecache] [--noupdateuser] [--limit|l]\n"; 46my $dry_run = 0; 47my $debug = 0; 48my $verbose = 0; 49my $interval = 100; # frequency at which we notify how things are going 50 51my $cache; # cache of previously encountered hashes (default undef) 52my $updateuser = 1; # update user database (default yes) 53my $savecache = 1; # save hash DB if specified (default yes) 54my $limit = 0; # number of users to update (default all) 55my $config = Conf::get_sympa_conf(); # config file to use 56 57my %options; 58 59GetOptions( 60 \%main::options, 'cache|c=s', 'nosavecache', 'noupdateuser', 61 'limit|l=i', 'config=s', 'dry_run|n', 'debug|d', 62 'verbose|v' 63); 64 65$cache = $main::options{'cache'}; 66$config = $main::options{'config'} if defined($main::options{'config'}); 67$debug = defined($main::options{'debug'}); 68$verbose = defined($main::options{'verbose'}); 69$dry_run = defined($main::options{'dry_run'}); 70$savecache = !defined($main::options{'nosavecache'}); 71$updateuser = !defined($main::options{'noupdateuser'}); 72$limit = $main::options{'limit'} || 0; 73 74STDOUT->autoflush(1); 75 76# 77# For safety, dry_run disables all modifications 78# 79if ($dry_run) { 80 $savecache = $updateuser = 0; 81} 82 83die 'Error in configuration' 84 unless Conf::load($config, 'no_db'); 85 86# Get obsoleted parameter. 87open my $fh, '<', $config or die $ERRNO; 88my ($cookie) = 89 grep {defined} map { /\A\s*cookie\s+(\S+)/s ? $1 : undef } <$fh>; 90close $fh; 91 92my $password_hash = Conf::get_robot_conf('*', 'password_hash'); 93my $bcrypt_cost = Conf::get_robot_conf('*', 'bcrypt_cost'); 94 95# 96# Handle the cache if specfied 97# 98my $hashes = {}; 99my $hashes_changed = 0; 100 101if (defined($cache) && (-e $cache)) { 102 print "Reading precalculated hashes from $cache\n"; 103 $hashes = read_hashes($cache = $main::options{'cache'}); 104} 105 106# 107# Retrieve user records and update each in turn 108# 109print "Recoding password using $password_hash fingerprint.\n"; 110$dry_run && print "dry_run: database will *not* be updated.\n"; 111 112my $sdm = Sympa::DatabaseManager->instance 113 or die 'Can\'t connect to database'; 114my $sth; 115 116# Check if RC4 decryption required. 117$sth = $sdm->do_prepared_query( 118 q{SELECT COUNT(*) FROM user_table WHERE password_user LIKE 'crypt.%'}); 119my ($encrypted) = $sth->fetchrow_array; 120if ($encrypted and not $Crypt::CipherSaber::VERSION) { 121 die 122 "Password seems encrypted while Crypt::CipherSaber is not installed!\n"; 123} 124 125$sth = $sdm->do_query(q{SELECT email_user, password_user from user_table}); 126unless ($sth) { 127 die 'Unable to prepare SQL statement'; 128} 129 130my $total = {}; 131my $count = 0; 132my $hash_time; 133 134while (my $user = $sth->fetchrow_hashref('NAME_lc')) { 135 my $clear_password; 136 137 # if a limit is set, only process that many user records (i.e. for testing) 138 last if ($limit && (++$count > $limit)); 139 140 # Ignore empty passwords 141 next 142 unless defined $user->{'password_user'} 143 and length $user->{'password_user'}; 144 145 if ($user->{'password_user'} =~ /^[0-9a-f]{32}/) { 146 printf "Password from %s already encoded as md5 fingerprint\n", 147 $user->{'email_user'}; 148 $total->{'md5'}++; 149 next; 150 } 151 152 if ($user->{'password_user'} =~ /^\$2a\$/) { 153 printf "Password from %s already encoded as bcrypt fingerprint\n", 154 $user->{'email_user'}; 155 $total->{'bcrypt'}++; 156 next; 157 } 158 159 if ($user->{'password_user'} =~ /\Acrypt[.](.*)\z/) { 160 # Old style RC4 encrypted password. 161 $clear_password = _decrypt_rc4_password($user->{'password_user'}); 162 } else { 163 # Old style cleartext password. 164 $clear_password = $user->{'password_user'}; 165 } 166 167 ## do we have a precalculated hash for this user/password/hashtype? 168 169 my $checksum = checksum($clear_password); 170 my $email_user = $user->{'email_user'}; 171 my $prehash = $hashes->{$email_user}; 172 my $newhash; 173 174 if ( defined($hashes->{$email_user}) 175 && ($hashes->{$email_user}->{'type'} eq $password_hash) 176 && ($hashes->{$email_user}->{'checksum'} eq $checksum)) { 177 178 $newhash = $hashes->{$email_user}->{'hash'}; 179 printf "pre $email_user $newhash\n" if ($debug); 180 $total->{'prehashes'}++; 181 182 } else { 183 $hashes_changed = 1; 184 # track how long it takes (cheap with MD5, expensive with Bcrypt) 185 my $starttime = [gettimeofday]; 186 $newhash = Sympa::User::password_fingerprint($clear_password, undef); 187 my $elapsed = tv_interval($starttime, [gettimeofday]); 188 189 $total->{'newhash_time'} += $elapsed; 190 $total->{'newhashes'}++; 191 192 $hashes->{$email_user} = { 193 'email_user' => $email_user, 194 'checksum' => $checksum, 195 'type' => $password_hash, 196 'hash' => $newhash 197 }; 198 printf "new hash $email_user $newhash\n" if ($debug); 199 } 200 201 $total->{'updated'}++; 202 203 # notify along the way if in verbose mode. most useful for larger sites 204 if ($verbose && (($total->{'updated'} % $interval) == 0)) { 205 printf 'Processed %d users', $total->{'updated'}; 206 if ($total->{'newhashes'}) { 207 printf 208 ", %d new hashes in %.3f sec, %.4f sec/hash %.2f hash/sec", 209 $total->{'newhashes'}, $total->{'newhash_time'}, 210 $total->{'newhash_time'} / $total->{'newhashes'}, 211 $total->{'newhashes'} / $total->{'newhash_time'}; 212 } 213 print "\n"; 214 } 215 216 ## Updating Db 217 218 next unless ($updateuser); 219 220 unless ( 221 $sdm->do_prepared_query( 222 q{UPDATE user_table 223 SET password_user = ? 224 WHERE email_user = ?}, 225 $newhash, 226 $user->{'email_user'} 227 ) 228 ) { 229 die 'Unable to execute SQL statement'; 230 } 231} 232 233$sth->finish(); 234 235# save hashes for later if hash db file is specified 236if (defined($cache) && $savecache && $hashes_changed) { 237 printf "Saving hashes in $cache\n"; 238 save_hashes($cache, $hashes); 239} 240 241# print a roundup of changes 242 243foreach my $hash_type ('md5', 'bcrypt') { 244 if ($total->{$hash_type}) { 245 printf 246 "Found in table user %d passwords stored using %s. Did you run Sympa before upgrading?\n", 247 $total->{$hash_type}, $hash_type; 248 } 249} 250printf 251 "Updated %d user passwords in table user_table using $password_hash hashes.\n", 252 ($total->{'updated'} || 0); 253 254if ($total->{'newhashes'}) { 255 my $elapsed = $total->{'newhash_time'}; 256 my $new = $total->{'newhashes'}; 257 printf 258 "Time required to calculate new %s hashes: %.2f seconds %.5f sec/hash\n", 259 $password_hash, $total->{'newhash_time'}, 260 ($total->{'newhash_time'} / $total->{'newhashes'}); 261 if ($password_hash eq 'bcrypt') { 262 printf "Bcrypt cost setting: %d\n", $bcrypt_cost; 263 } 264} 265 266if ($total->{'prehashes'}) { 267 printf 268 "Used %d precalculated hashes to reduce compute time.\n", 269 $total->{'prehashes'}; 270} 271 272exit 0; 273 274my $rc4; 275 276# decrypt RC4 encrypted password. 277# Old name: Sympa::Tools::Password::decrypt_password(). 278sub _decrypt_rc4_password { 279 my $inpasswd = shift; 280 281 return $inpasswd unless $inpasswd =~ /\Acrypt[.](.*)\z/; 282 $inpasswd = $1; 283 284 $rc4 = Crypt::CipherSaber->new($cookie) unless $rc4; 285 return $rc4->decrypt(MIME::Base64::decode($inpasswd)); 286} 287 288# 289# Here we use MD5 as a quick way to make sure that a precalculated hash 290# is still valid. 291# 292sub checksum { 293 my ($data) = @_; 294 295 return Digest::MD5::md5_hex($data); 296} 297 298# 299# The hash file format could not be simpler: space separated columns. 300# email_user checksum type hash 301# 302 303sub read_hashes { 304 my ($f) = @_; 305 my $h = {}; 306 307 open(HASHES, "<$f") || die "$0: read_hashes: open $f: $!\n"; 308 while (<HASHES>) { 309 next if (/^$/ || /^\#/); # ignore blank lines/comments 310 chomp; 311 my ($email, $checksum, $type, $hash) = split(/ /, $_, 4); 312 313 warn "$0: parse error: $_\n", next 314 unless ($email && $checksum && $type && $hash); 315 die "$0: $email: unsupported hash type $type\n" 316 unless ($type =~ /^(md5|bcrypt)$/); 317 318 $h->{$email} = { 319 'email_user' => $email, 320 'checksum' => $checksum, 321 'type' => $type, 322 'hash' => $hash 323 }; 324 } 325 close(HASHES); 326 327 return $h; 328} 329 330sub save_hashes { 331 my ($f, $h) = @_; 332 333 my $tmpfile = "$f.tmp.$$"; 334 335 open(HASHES, ">$tmpfile") || die "$0: save_hashes: open $tmpfile: $!\n"; 336 337 # prevent world/group access 338 chmod 0600, $tmpfile; 339 340 foreach my $email_user (sort keys %$h) { 341 my $u = $h->{$email_user}; 342 printf HASHES "%s %s %s %s\n", 343 $u->{'email_user'}, $u->{'checksum'}, 344 $u->{'type'}, $u->{'hash'}; 345 } 346 close(HASHES); 347 348 rename($f, "$f.old"); 349 rename($tmpfile, $f); 350} 351 352__END__ 353 354=encoding utf-8 355 356=head1 NAME 357 358upgrade_sympa_password, upgrade_sympa_password.pl - 359Upgrading password in database 360 361=head1 SYNOPSIS 362 363 upgrade_sympa_password.pl [--dry_run|-n] [--debug|d] [--verbose|v] [--config file ] [--cache file] [--nosavecache] [--noupdateuser] [--limit|l number_of_users] 364 365=head1 OPTIONS 366 367=over 368 369=item --dry_run|-n 370 371Shows what will be done but won't really perform the upgrade process. 372 373=item --debug|-d 374 375Print additional debugging information during the upgrade process. 376 377=item --verbose|-v 378 379Print verbose logging messages during the upgrade process. 380 381=item --config FILENAME 382 383Specify the pathname of the file to use as the Sympa configuration file. 384Otherwise the system default Sympa configuration file is used. 385 386=item --cache FILENAME 387 388Specify the pathname of a file to store precalculated hashes for reuse on 389subsequent runs of the script. 390 391The file is created if it does not already exist. 392 393This option is useful for large sites using intentionally expensive 394password hashes such as bcrypt. In that case this script can be run in 395advance to precalculate hashes and reduce the time required during the 396final upgrade process. 397 398WARNING: since it contains sensitive password data, this file should 399be protected as carefully as any other password file, or a database 400dump of the Sympa user_table. 401 402=item --nosavecache 403 404Disables updates of the cache. The cache is still consulted if specified with C<--cache>. 405 406=item --noupdateuser 407 408Disables updates of the user_table. Mostly useful when precalculating user 409hashes in advance. 410 411=back 412 413=head1 DESCRIPTION 414 415Versions later than 5.4 use one-way hashes instead of symmetric encryption to 416store passwords. This script upgrades any symmetric encrypted passwords it finds to one-way hashes. 417 418Versions later than 6.2.26 support bcrypt. 419 420This upgrade requires to rewriting user password entries in the database. 421This upgrade IS NOT REVERSIBLE. 422 423=head1 HISTORY 424 425As of Sympa 3.1b.7, passwords may be stored into user table with encrypted 426form by reversible RC4. 427 428Sympa 5.4 or later uses MD5 one-way hash function to encode user passwords. 429 430Sympa 6.2.26 or later has optional support for bcrypt. 431 432 433=cut 434