Commit 744aaf3a authored by Mic Kaczmarczik's avatar Mic Kaczmarczik
Browse files

* avoid errors about undef values in strings from Perl strict mode
* only accept old hashes when password_hash_update is set to true
* new command line options and associated POD documentation
* support for precaching hashes (useful for big sites using bcrypt)
* measure how long it takes to generate a hash (useful for bcrypt users)
* report on progress every 100 users processed when in verbose mode
parent 549024a0
......@@ -34,30 +34,88 @@ use Conf;
use Sympa::DatabaseManager;
use Sympa::Tools::Password;
use Sympa::User;
use Digest::MD5;
use Getopt::Long;
use Time::HiRes qw(gettimeofday tv_interval);
my $usage = "Usage: $0 [--dry_run|n] [--debug|d] [--verbose|v] [--config file] [--cache file] [--nosavecache] [--noupdateuser] [--limit|l]\n";
my $dry_run = 0;
my $debug = 0;
my $verbose = 0;
my $interval = 100; # frequency at which we notify how things are going
my $cache; # cache of previously encountered hashes (default undef)
my $updateuser = 1; # update user database (default yes)
my $savecache = 1; # save hash DB if specified (default yes)
my $limit = 0; # number of users to update (default all)
my $config = Conf::get_sympa_conf(); # config file to use
my %options;
GetOptions(\%main::options, 'cache|c=s', 'nosavecache', 'noupdateuser',
'limit|l=i', 'config=s', 'dry_run|n', 'debug|d', 'verbose|v');
$cache = $main::options{'cache'};
$config = $main::options{'config'} if defined($main::options{'config'});
$debug = defined($main::options{'debug'});
$verbose = defined($main::options{'verbose'});
$dry_run = defined($main::options{'dry_run'});
$savecache = !defined($main::options{'nosavecache'});
$updateuser = !defined($main::options{'noupdateuser'});
$limit = $main::options{'limit'} || 0;
# For safety, dry_run disables all modifications
if ($dry_run) {
$savecache = $updateuser = 0;
die "Crypt::CipherSaber not installed ; cannot crypt passwords"
unless $Crypt::CipherSaber::VERSION;
die 'Error in configuration'
unless Conf::load(Conf::get_sympa_conf(), 'no_db');
my $sdm = Sympa::DatabaseManager->instance
or die 'Can\'t connect to database';
unless Conf::load($config, 'no_db');
my $password_hash = Conf::get_robot_conf('*', 'password_hash');
my $bcrypt_cost = Conf::get_robot_conf('*', 'bcrypt_cost');
# Handle the cache if specfied
my $hashes = {};
my $hashes_changed = 0;
if (defined($cache) && (-e $cache)) {
print "Reading precalculated hashes from $cache\n";
$hashes = read_hashes($cache = $main::options{'cache'});
# Retrieve user records and update each in turn
print "Recoding password using $password_hash fingerprint.\n";
$dry_run && print "dry_run: database will *not* be updated.\n";
my $sdm = Sympa::DatabaseManager->instance
or die 'Can\'t connect to database';
my $sth = $sdm->do_query(q{SELECT email_user, password_user from user_table});
unless ($sth) {
die 'Unable to prepare SQL statement';
my $total = 0;
my $total_md5 = 0;
my $total_bcrypt = 0;
my $total = {};
my $count = 0;
my $hash_time;
while (my $user = $sth->fetchrow_hashref('NAME_lc')) {
my $clear_password;
# if a limit is set, only process that many user records (i.e. for testing)
last if ($limit && (++$count > $limit));
# Ignore empty passwords
unless defined $user->{'password_user'}
......@@ -66,14 +124,14 @@ while (my $user = $sth->fetchrow_hashref('NAME_lc')) {
if ($user->{'password_user'} =~ /^[0-9a-f]{32}/) {
printf "Password from %s already encoded as md5 fingerprint\n",
if ($user->{'password_user'} =~ /^\$2a\$/) {
printf "Password from %s already encoded as bcrypt fingerprint\n",
......@@ -84,36 +142,176 @@ while (my $user = $sth->fetchrow_hashref('NAME_lc')) {
$clear_password = $user->{'password_user'};
## do we have a precalculated hash for this user/password/hashtype?
my $checksum = checksum($clear_password);
my $email_user = $user->{'email_user'};
my $prehash = $hashes->{$email_user};
my $newhash;
if (defined($hashes->{$email_user})
&& ($hashes->{$email_user}->{'type'} eq $password_hash)
&& ($hashes->{$email_user}->{'checksum'} eq $checksum)) {
$newhash = $hashes->{$email_user}->{'hash'};
printf "pre $email_user $newhash\n" if ($debug);
} else {
$hashes_changed = 1;
# track how long it takes (cheap with MD5, expensive with Bcrypt)
my $starttime = [gettimeofday];
$newhash = Sympa::User::password_fingerprint($clear_password, undef);
my $elapsed = tv_interval($starttime, [gettimeofday]);
$total->{'newhash_time'} += $elapsed;
$hashes->{$email_user} = { 'email_user' => $email_user,
'checksum' => $checksum,
'type' => $password_hash,
'hash' => $newhash };
printf "new hash $email_user $newhash\n" if ($debug);
# notify along the way if in verbose mode. most useful for larger sites
if ($verbose && (($total->{'updated'} % $interval) == 0)) {
printf 'Processed %d users', $total->{'updated'};
if ($total->{'newhashes'}) {
", %d new hashes in %.3f sec, %.4f sec/hash %.2f hash/sec",
$total->{'newhashes'}, $total->{'newhash_time'},
$total->{'newhash_time'} / $total->{'newhashes'},
$total->{'newhashes'} / $total->{'newhash_time'};
print "\n";
## Updating Db
next unless ($updateuser);
unless (
q{UPDATE user_table
SET password_user = ?
WHERE email_user = ?},
Sympa::User::password_fingerprint($clear_password, undef),
) {
die 'Unable to execute SQL statement';
# save hashes for later if hash db file is specified
if (defined($cache) && $savecache && $hashes_changed) {
printf "Saving hashes in $cache\n";
save_hashes($cache, $hashes);
# print a roundup of changes
foreach my $hash_type ('md5', 'bcrypt') {
if ($total->{$hash_type}) {
"Found in table user %d passwords stored using %s. Did you run Sympa before upgrading?\n",
$total->{$hash_type}, $hash_type;
"Updating password storage in table user_table using $password_hash hashes for %d users.\n",
if ($total_md5 || $total_bcrypt) {
"Updated %d user passwords in table user_table using $password_hash hashes.\n",
($total->{'updated'} || 0);
if ($total->{'newhashes'}) {
my $elapsed = $total->{'newhash_time'};
my $new = $total->{'newhashes'};
"Found in table user %d password stored using md5, %d using bcrypt. Did you run Sympa before upgrading ?\n",
$total_md5, $total_bcrypt;
"Time required to calculate new %s hashes: %.2f seconds %.5f sec/hash\n",
$password_hash, $total->{'newhash_time'},
($total->{'newhash_time'} / $total->{'newhashes'});
if ($password_hash eq 'bcrypt') {
printf "Bcrypt cost setting: %d\n", $bcrypt_cost;
printf "Total passwords re-encoded using $password_hash: %d\n", $total;
if ($total->{'prehashes'}) {
"Used %d precalculated hashes to reduce compute time.\n",
exit 0;
# Here we use MD5 as a quick way to make sure that a precalculated hash
# is still valid.
sub checksum {
my ($data) = @_;
return Digest::MD5::md5_hex($data);
# The hash file format could not be simpler: space separated columns.
# email_user checksum type hash
sub read_hashes {
my ($f) = @_;
my $h = {};
open(HASHES, "<$f") || die "$0: read_hashes: open $f: $!\n";
while(<HASHES>) {
next if (/^$/ || /^\#/); # ignore blank lines/comments
my ($email, $checksum, $type, $hash) = split(/ /, $_, 4);
warn "$0: parse error: $_\n", next
unless ($email && $checksum && $type && $hash);
die "$0: $email: unsupported hash type $type\n"
unless ($type =~ /^(md5|bcrypt)$/);
$h->{$email} = {
'email_user' => $email,
'checksum' => $checksum,
'type' => $type,
'hash' => $hash
return $h;
sub save_hashes {
my ($f, $h) = @_;
my $tmpfile = "$f.tmp.$$";
open(HASHES, ">$tmpfile") || die "$0: save_hashes: open $tmpfile: $!\n";
# prevent world/group access
chmod 0600, $tmpfile;
foreach my $email_user (sort keys %$h) {
my $u = $h->{$email_user};
printf HASHES "%s %s %s %s\n",
$u->{'email_user'}, $u->{'checksum'},
$u->{'type'}, $u->{'hash'};
rename($f, "$f.old");
rename($tmpfile, $f);
=encoding utf-8
......@@ -123,10 +321,62 @@ __END__
upgrade_sympa_password, -
Upgrading password in database
=head1 SYNOPSIS [--dry_run|-n] [--debug|d] [--verbose|v] [--config file ] [--cache file] [--nosavecache] [--noupdateuser] [--limit|l number_of_users]
=head1 OPTIONS
=item --dry_run|-n
Shows what will be done but won't really perform the upgrade process.
=item --debug|-d
Print additional debugging information during the upgrade process.
=item --verbose|-v
Print verbose logging messages during the upgrade process.
=item --config FILENAME
Specify the pathname of the file to use as the Sympa configuration file.
Otherwise the system default Sympa configuration file is used.
=item --cache FILENAME
Specify the pathname of a file to store precalculated hashes for reuse on
subsequent runs of the script.
The file is created if it does not already exist.
This option is useful for large sites using intentionally expensive
password hashes such as bcrypt. In that case this script can be run in
advance to precalculate hashes and reduce the time required during the
final upgrade process.
WARNING: since it contains sensitive password data, this file should
be protected as carefully as any other password file, or a database
dump of the Sympa user_table.
=item --nosavecache
Disables updates of the cache. The cache is still consulted if specified with C<--cache>.
=item --noupdateuser
Disables updates of the user_table. Mostly useful when precalculating user
hashes in advance.
Versions later than 5.4 uses MD5 hash instead of
symmetric encryption to store password.
Versions later than 5.4 use one-way hashes instead of symmetric encryption to
store passwords. This script upgrades any symmetric encrypted passwords it finds to one-way hashes.
Versions later than 6.2.26 support bcrypt.
......@@ -142,4 +392,5 @@ Sympa 5.4 or later uses MD5 one-way hash function to encode user passwords.
Sympa 6.2.26 or later has optional support for bcrypt.
......@@ -326,7 +326,7 @@ my %fingerprint_hashes = (
# A bcrypt-encrypted password contains the settings at the front.
# If this not look like a settings string, create one.
unless ($salt =~ m#\A\$2(a?)\$([0-9]{2})\$([./A-Za-z0-9]{22})#x) {
unless (defined($salt) && $salt =~ m#\A\$2(a?)\$([0-9]{2})\$([./A-Za-z0-9]{22})#x) {
my $bcrypt_cost = Conf::get_robot_conf('*', 'bcrypt_cost');
my $cost = sprintf("%02d", 0 + $bcrypt_cost);
my $newsalt = "";
......@@ -335,7 +335,7 @@ my %fingerprint_hashes = (
$newsalt .= chr(rand(256));
$newsalt = '$2a$' . $cost . '$' . en_base64($newsalt);
$log->syslog('debug', "bcrypt: create new salt: cost $cost salt \"$salt\" salt \"$newsalt\"");
$log->syslog('debug', "bcrypt: create new salt: cost $cost \"$newsalt\"");
$salt = $newsalt;
......@@ -352,23 +352,26 @@ my %fingerprint_hashes = (
sub password_fingerprint {
my ($pwd, $salt) = @_;
my $password_hash;
my $hash_type;
$log->syslog('debug', "salt \"$salt\"");
$log->syslog('debug', "salt \"%s\"", $salt);
my $password_hash = Conf::get_robot_conf('*', 'password_hash');
my $password_hash_update =
Conf::get_robot_conf('*', 'password_hash_update');
if (Conf::get_robot_conf('*', 'password_case') eq 'insensitive') {
$pwd = lc($pwd);
# preserve the hash type if we can determine it, else use system default
if (defined($salt) && defined($hash_type = hash_type($salt))) {
$password_hash = $hash_type;
} else {
$password_hash = Conf::get_robot_conf('*', 'password_hash');
# If updating hashes, honor the hash type implied by $salt. This lets
# the user successfully log in, after which the hash can be updated
$log->syslog('debug', "hash_type \"$hash_type\", password_hash = \"$password_hash\"");
if ($password_hash_update) {
if (defined($salt) && defined(my $hash_type = hash_type($salt))) {
$log->syslog('debug', "honoring hash_type %s", $hash_type);
$password_hash = $hash_type;
die "password_fingerprint: unknown password_hash \"$password_hash\""
unless defined($fingerprint_hashes{$password_hash});
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment