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