This post is based on https://kaworu.ch/blog/2016/04/20/strong-crypt-scheme-with-dovecot-postfixadmin-and-roundcube/ with adjustments for my situation and updated links to the documentation. I use PostfixAdmin 3.3.15, Roundcube 1.6.11 and Dovecot Community Edition (CE) 2.4.1 .
When I was migrating from Debian
bookworm to
trixie, I noticed that Dovecot was
upgraded to version 2.4.1 and
because I had to enable the deprecated auth_allow_weak_schemes option, I
thought it would be a good idea to look at how to improve the security of the
passwords that are stored for my email setup.
On the documentation site for Dovecot CE there
is a nice page on Converting Password
Schemes
which pointed to the site where is post is based on.
Originally I choose to use the MD5-CRYPT scheme, because it was the most
universal supported scheme which was not plain text. Now it is time to move to
something more secure.
Check which scheme to use
The Dovecot CE documentation says that the best scheme is ARGON2I or ARGON2ID, but these are slower than for example BLF-CRYPT. I ran these commands on the system where the mail is being handled, so I could see how much time it would costs to calculate the hash.
# time doveadm pw -s ARGON2ID -p secret
{ARGON2ID}$argon2id$v=19$m=65536,t=3,p=1$vOYJ0Sm/nb0i1yPdOo0PaQ$s8q5gCNktv1SX6yZUmkRct5qU34noEQL7Y4G2aVK4cQ
real 0m0.820s
user 0m0.665s
sys 0m0.066s
# time doveadm pw -s ARGON2I -p secret
{ARGON2I}$argon2i$v=19$m=32768,t=4,p=1$B5Kn40XIs0ATDp5yiC7dVw$mA/NWkO1k6hQLXlnuSYhMKbZp7PWYvudtuMjxcUAH74
real 0m0.491s
user 0m0.445s
sys 0m0.043s
# time doveadm pw -s BLF-CRYPT -p secret
{BLF-CRYPT}$2y$05$Qtovd3Uzw6fWk/nIh4OBgeAN2M01p/Us3rP9r1NmQfST.DygLc0hO
real 0m0.053s
user 0m0.034s
sys 0m0.017s
#
Based on these numbers, I chose BLF-CRYPT for my setup. Because this doesn’t take more than 250ms to compute the hash. Otherwise a user will experience such a delay with every login.
Determine the number of rounds
Without any additional rounds for the BLF-CRYPT scheme it is fast to login, but also fast to crack, so I wanted to add additional rounds, but still stay under the 250ms.
# for round in $(seq 4 12); do echo "rounds: ${round}"; time doveadm pw -s BLF-CRYPT -r ${round} -p secret; done
rounds: 4
{BLF-CRYPT}$2y$04$xmaXD0VqFNmRUwq/7.YZLOatMm84DPx9anXOTc5rxUbBNGPEAoEda
real 0m0.048s
user 0m0.034s
sys 0m0.011s
...
rounds: 9
{BLF-CRYPT}$2y$09$V2cF4y2s/nxe0LFQlIMks.fPJUFDaCug8O3lOTmx0lBljlFCiDAYe
real 0m0.098s
user 0m0.083s
sys 0m0.013s
rounds: 10
{BLF-CRYPT}$2y$10$maI6dIPj9/idnQ.m73ZdF.yiFh7/LbIVF3wH00id14cJeN0x6s0AS
real 0m0.148s
user 0m0.129s
sys 0m0.016s
rounds: 11
{BLF-CRYPT}$2y$11$298DxQT9t285KwnsdqNqFOtt1kPR7Ftx15apNXBAUnIEMe1MRGFS2
real 0m0.249s
user 0m0.234s
sys 0m0.012s
rounds: 12
{BLF-CRYPT}$2y$12$y0y8wfk0HgOc.lqKcXXSM.NtfAcWhcaqqyH2hMbUVKg/s5Xa5nGcW
real 0m0.441s
user 0m0.423s
sys 0m0.016s
#
So based on these calculations I choose 11 rounds, which takes about 249ms.
PostfixAdmin configuration
The configuration for PostfixAdmin is done in the config.local.php
<?php
// php_crypt:CRYPT-METHOD:DIFFICULTY:PREFIX = use PHP built in crypt()-function. Example: php_crypt:SHA512:50000
// - php_crypt CRYPT-METHOD: Supported values are DES, MD5, BLOWFISH, SHA256, SHA512
// - php_crypt DIFFICULTY: Larger value is more secure, but uses more CPU and time for each login.
// - php_crypt DIFFICULTY: Set this according to your CPU processing power.
// - php_crypt DIFFICULTY: Supported values are BLOWFISH:4-31, SHA256:1000-999999999, SHA512:1000-999999999
// - php_crypt DIFFICULTY: leave empty to use default values (BLOWFISH:10, SHA256:5000, SHA512:5000). Example: php_crypt:SHA512
// - php_crypt PREFIX: hash has specified prefix - example: php_crypt:SHA512::{SHA512-CRYPT}
//
// sha512.b64 - {SHA512-CRYPT.B64} (base64 encoded sha512) (no dovecot dependency; should support migration from md5crypt)
//$CONF['encrypt'] = 'BLF-CRYPT';
$CONF['encrypt'] = 'php_crypt:BLOWFISH:11:{BLF-CRYPT}';
?>
Old password scheme backward compatibility
If you know the previous password scheme, which should have been the previous
setting in the config.local.php, you can skip the step to look at the previous
hash. If you don’t know it, have a look at the previous password hash to
determine the scheme.
$ psql -c "SELECT password FROM admin LIMIT 1" postfixadmin
password
------------------------------------
$1$Q/R19/rg$NR0GDPiZL5wC5qPZqkZXA.
(1 row)
$
This shows the hash for a MD5 scheme. If your hash does not start with $1$,
you had a different scheme and you can look it up in the Key deveration
functions list by
crypt
and if that doesn’t help you, you could try all supported schemes in doveadm pw:
for scheme in $(doveadm pw -l); do
doveadm pw -s ${scheme} -p secret;
done
To be able to login when the $CONF['encrypt'] setting has been changed, I had
to change the password fields in the database by prepending the scheme infront
of the password hash. This should not block the login for any user, but you
might want to start with one account and maybe only the admin table.
postfixadmin=# UPDATE admin SET password = '{MD5-CRYPT}' || password;
postfixadmin=# UPDATE mailbox SET password = '{MD5-CRYPT}' || password;
PostfixAdmin checkup
To check that the change to the password of the admin table has worked. I did the following steps:
- Login to PostfixAdmin with my admin account. This still uses the old password scheme
- Change the password via the webinterface. (This can be the same password, it just has to write it again with the new scheme)
- Check that the new password scheme is used with
SELECT password FROM admin ORDER BY modified DESC LIMIT 1; - Logout and login again. If this doesn’t work, restore a backup.
- Create a new mailbox and check if the new password scheme is used with
SELECT password FROM mailbox ORDER BY modified DESC LIMIT 1;
Roundcube password plugin configuration
To make it possible for a user to change their password in the roundcube
(1.6.11) webmail, the password plugin has to be installed and configured.
The configuration of the plugin itself at
/etc/roundcube/plugins/password/config.inc.php (this is the Debian location)
should have some of the following settings.
1<?php
2// See /usr/share/roundcube/plugins/password/config.inc.php.dist for instructions
3// Check the access right of the file if you put sensitive information in it.
4$config=array();
5
6// Determine whether current password is required to change password.
7// Default: false.
8$config['password_confirm_current'] = true;
9
10// Enables logging of password changes into logs/password
11$config['password_log'] = false;
12
13// Enables saving the new password even if it matches the old password. Useful
14// for upgrading the stored passwords after the encryption scheme has changed.
15$config['password_force_save'] = true;
16
17// Password hashing/crypting algorithm.
18// Possible options: des-crypt, ext-des-crypt, md5-crypt, blowfish-crypt,
19// sha256-crypt, sha512-crypt, md5, sha, smd5, ssha, ssha256, ssha512, samba, ad
20, dovecot, clear.
21// Also supported are password_hash() algoriths: hash-bcrypt, hash-argon2i, hash
22-argon2id.
23// Default: 'clear' (no hashing)
24// For details see password::hash_password() method.
25$config['password_algorithm'] = 'blowfish-crypt';
26
27// Additional options for password hashing function(s).
28// For password_hash()-based passwords see https://www.php.net/manual/en/function.password-hash.php
29// It can be used to set the Blowfish algorithm cost, e.g. ['cost' => 12]
30$config['password_algorithm_options'] = ['cost' => 11];
31
32// Password prefix (e.g. {CRYPT}, {SHA}) for passwords generated
33// using password_algorithm above. Default: empty.
34$config['password_algorithm_prefix'] = '{BLF-CRYPT}';
35
36// SQL Driver options
37// ------------------
38// PEAR database DSN for performing the query. By default
39// Roundcube DB settings are used.
40// Supported replacement variables:
41// %h - user's IMAP hostname
42// %n - hostname ($_SERVER['SERVER_NAME'])
43// %t - hostname without the first part
44// %d - domain (http hostname $_SERVER['HTTP_HOST'] without the first part)
45// %z - IMAP domain (IMAP hostname without the first part)
46$config['password_db_dsn'] = 'pgsql://postfixadmin:the_password_for_the_database@localhost/postfixadmin';
47
48// The SQL query used to change the password.
49// The query can contain the following macros that will be expanded as follows:
50// %p is replaced with the plaintext new password
51// %P is replaced with the crypted/hashed new password
52// according to configured password_algorithm
53// %o is replaced with the old (current) password
54// %O is replaced with the crypted/hashed old (current) password
55// according to configured password_algorithm
56// %h is replaced with the imap host (from the session info)
57// %u is replaced with the username (from the session info)
58// %l is replaced with the local part of the username
59// (in case the username is an email address)
60// %d is replaced with the domain part of the username
61// (in case the username is an email address)
62// Escaping of macros is handled by this module.
63// Default: "SELECT update_passwd(%P, %u)"
64$config['password_query'] = 'UPDATE mailbox SET password = %P, modified = NOW() WHERE username = %u';
65?>
Some notes on the configuration above.
- On line 11 you can enable the logging of the passwords in the
/var/log/roundcube/*.loglog file for debug purposes. This should not be set totruein production. - The
password_algorithm_optionsandpassword_algorithm_prefixshould be the same as was used in the PostfixAdmin configuration. - The SQL statement on line 64 could be better if everyone had already converted to the new password scheme as mentioned in the password plugin’s README, but since we are just beginning we use this simple one.
Roundcube checkup
To check if the password change is working in roundcube, follow these steps:
- Login at Roundcube. (This still uses the old password scheme.)
- Once logged in, change your password via the settings -> password option. And
when that is done you should be able to see the new password scheme in the
database with
SELECT password FROM mailbox ORDER BY modified DESC LIMIT 1;. - Logout and login again to make sure everything works as expected.
Automatic password scheme migration
Dovecot can run post-login scripts which can be used to migrate the passwords to the new password scheme we want.
Dovecot config
The passdb sql query has to be changed to also return the plaintext password
to the dovecot processes. This will not be visible to a user on the system or
elsewhere. Therefor '%{password}' as userdb_plain_pass is added to the
SELECT query. Before the change we had:
passdb sql {
query = SELECT username, domain, password \
FROM mailbox \
WHERE username = '%{user}' AND active='t'
}
Notice there is an extra , after password.
And after the change we have:
passdb sql {
query = SELECT username, domain, password, \
'%{password}' as userdb_plain_pass \
FROM mailbox \
WHERE username = '%{user}' AND active='t'
}
With this change, I was able to login to roundcube, but I was not able to see any emails. The dovecot debug logging showed the following:
13:16:30 lmtp(45862): Debug: lmtp-server: conn unix:pid=45861,uid=115 [1]: Received new command: RCPT TO:<blowfish@aram-a.example.org>
13:16:30 lmtp(45862): Debug: lmtp-server: conn unix:pid=45861,uid=115 [1]: command RCPT: New command
....
13:16:30 auth: Debug: master in: USER 1 blowfish@aram-a.example.org protocol=lmtp
13:16:30 auth(blowfish): Debug: passwd: Performing userdb lookup
....
13:16:30 auth(blowfish): Debug: sql: Performing userdb lookup
13:16:30 auth(blowfish): Debug: sql: SELECT '/home/mail/virtual' || maildir AS home, 9999 AS uid, 9999 AS gid FROM mailbox WHERE username = 'blowfish' AND active = 't'
So apparently the `%{user}’ is not the full username during the LMTP phase.
Other logging that shows why it fails
16:21:50 auth: Debug: pgsql(localhost): Finished query 'SELECT '/home/mail/virtual/' || maildir AS home, 9999 AS uid, 9999 AS gid FROM mailbox WHERE (username = 'blowfish' OR username = 'blowfish@aram-a.example.org') AND active='t'' in 4 msecs
16:21:50 auth(blowfish): Debug: sql: Finished userdb lookup
16:21:50 auth: Debug: userdb out: USER 1 blowfish home=/home/mail/virtual/aram-a.example.org/blowfish/ uid=9999 gid=9999
16:21:50 lmtp(blowfish@aram-a.example.org)<47244><DusBBP4J0GiMuAAAt+NjrQ>: Debug: lmtp-server: conn unix:pid=47243,uid=115 [1]: rcpt blowfish@aram-a.example.org: changed username to blowfish
16:21:50 lmtp(47244): Debug: lmtp-server: conn unix:pid=47243,uid=115 [1]: command RCPT: Next to reply
This gave me the hint to look for information on the internet for ‘dovecot lmtp changed username’ which gave me a site about Trouble with Postfix, LMTP, Dovecot and
PAM: “Unknown
user”.
Which shows at the end that auth_username_format = %Ln will make Dovecot strip
away the domain part. And apparently the /etc/dovecot/conf.d/20-lmtp.conf has
this set to:
protocol lmtp {
....
# This strips the domain name before delivery, since the default
# userdb in Debian is /etc/passwd, which doesn't include domain
# names in the user. If you're using a different userdb backend
# that does include domain names, you may wish to remove this. See
# https://doc.dovecot.org/2.4.0/howto/lmtp/exim.html and
# https://doc.dovecot.org/2.4.0/core/summaries/settings.html#auth_username_format
auth_username_format = %{user | username}
}
But because I only have virtual users which use the full email address as
username, I had to change this in the /etc/dovecot/local.conf to
protocol lmtp {
auth_username_format = %{user | lower}
}
After reloading dovecot everything worked as expected and I could continue with the rest.
And Dovecot has to be configured to use the Prefetch User
Database
feature. This is done by changing some things in the /etc/dovecot/local.conf
file:
userdb prefetch {
}
service imap {
executable = imap imap-postlogin
}
service imap-postlogin {
executable = script-login /etc/dovecot/postlogin-updatepw.php
user = $SET:default_internal_user
unix_listener imap-postlogin {
}
}
If you also provide the POP3 service, you would need a similar config for the POP3 service.
password scheme update script
Of course we have to create the /etc/dovecot/postlogin-updatepw.php file and
make it executable for the dovecot user. And since it will contain the
credentials for the PostfixAdmin database it should not be readable by anyone
else.
/usr/bin/install -m 750 -g dovecot -o root /dev/null /etc/dovecot/postlogin-updatepw.php
I used the php script from the original post with some minor adjustments.
1#!/usr/bin/env php
2<?php
3// PDO dsn
4$pdo_dsn = 'pgsql:host=localhost;dbname=postfixadmin;user=postfixadm;password=the_password_for_the_database';
5// $rcmail_config['password_dovecotpw']
6$dovecotpw = '/usr/bin/doveadm pw -r 11';
7// $rcmail_config['password_dovecotpw_method']
8$dovecotpw_method = 'BLF-CRYPT';
9// where we log
10$syslog_facility = LOG_MAIL;
11
12// grab what we care about from the env.
13$username = getenv("USER");
14$plainpass = getenv("PLAIN_PASS");
15
16// init syslog.
17$progname = basename(__FILE__);
18openlog($progname, LOG_PID, $syslog_facility);
19
20// connect to the database.
21try {
22 $dbh = new PDO($pdo_dsn);
23 $dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
24} catch (PDOException $e) {
25 syslog(LOG_CRIT, "database connection failed: {$e->getMessage()}");
26 goto out;
27}
28
29try {
30 // retrieve the user's current password.
31 $sth = $dbh->prepare('SELECT password FROM mailbox WHERE username = ?');
32 $sth->execute([$username]);
33 $oldpasswd = $sth->fetchColumn();
34 if (!$oldpasswd) {
35 syslog(LOG_WARNING, "unable to find the mailbox for $username");
36 goto out;
37 }
38
39 // bail out if the new password is empty.
40 if (strlen($plainpass) === 0) {
41 syslog(LOG_ERR, "empty password for $username!");
42 goto out;
43 }
44
45 // if we find the dovecot password method in the current password, it does
46 // not need to be updated.
47 if (strpos($oldpasswd, $dovecotpw_method) !== false) {
48 // only during testing syslog(LOG_INFO, "$username already use $dovecotpw_method, skipping.");
49 goto out;
50 }
51
52 // generate a new password using `doveadm pw' without passing plainpass
53 // on the cmdline for security concerns.
54 $process = proc_open("$dovecotpw -s $dovecotpw_method", [
55 0 => ['pipe', 'r'],
56 1 => ['pipe', 'w'],
57 2 => ['pipe', 'w'],
58 ], $pipes);
59 if (!is_resource($process)) {
60 syslog(LOG_CRIT, "proc_open() failed.");
61 goto out;
62 }
63 // $pipes now looks like this:
64 // 0 => writeable handle connected to child stdin
65 // 1 => readable handle connected to child stdout
66 // 2 => readable handle connected to child stderr
67 fclose($pipes[2]); // immediately close stderr as we don't need it.
68 fwrite($pipes[0], "$plainpass\n");
69 fwrite($pipes[0], "$plainpass\n");
70 fclose($pipes[0]);
71 $newpasswd = trim(stream_get_contents($pipes[1]));
72 fclose($pipes[1]);
73 $retval = proc_close($process);
74 if ($retval !== 0) {
75 syslog(LOG_ERR, "$dovecotpw exited with status $retval, expected 0.");
76 goto out;
77 }
78
79 // sanity check to ensure that the new password has been computed with the
80 // requested method.
81 if (strpos($newpasswd, $dovecotpw_method) === false) {
82 syslog(LOG_ERR, "unexpected $dovecotpw output.");
83 goto out;
84 }
85
86 // update the password in the database with the newly computed one.
87 $sth = $dbh->prepare('UPDATE mailbox SET password = :newpasswd, modified = NOW() WHERE username = :username AND password = :oldpasswd');
88 $success = $sth->execute([
89 ':newpasswd' => $newpasswd,
90 ':username' => $username,
91 ':oldpasswd' => $oldpasswd,
92 ]);
93
94 // "close" the database connection,
95 // see https://secure.php.net/manual/en/pdo.connections.php
96 $sth = null;
97 $dbh = null;
98} catch (PDOException $e) {
99 syslog(LOG_CRIT, "database query failed: {$e->getMessage()}");
100 goto out;
101}
102
103if ($success) {
104 syslog(LOG_INFO, "$username password successfully migrated to $dovecotpw_method.");
105} else {
106 syslog(LOG_CRIT, "$username password migration to $dovecotpw_method failed.");
107}
108
109// FALLTHROUGH
110out: // cleanup.
111
112// close syslog.
113closelog();
114
115/*
116 * We have to continue execution from what we get on the command line argument,
117 * i.e. $argv.
118 *
119 * see https://wiki.dovecot.org/PostLoginScripting
120 */
121
122// $argv[0] is our script (i.e. __FILE__), $argv[1] the next program to
123// execute, and $argv[2..] the next program's arguments.
124$next_exe = $argv[1];
125$next_argv = array_slice($argv, 2);
126pcntl_exec($next_exe, $next_argv);
- You have to change the database settings on line 4.
- Make sure you have the correct number of rounds in line 6
- I have made a comment of line 48, because it would give a lot of output, which is only needed when settings things up.
After these changes I reloaded Dovecot via systemctl reload dovecot to get the
new configuration active.
Testing the password migration
To test the new configuration I just logged in with a user via Roundcube and saw the following line in my mail.log:
postlogin-updatepw.php[36798]: test@aram-a.example.org password successfully migrated to BLF-CRYPT.
And I was able to confirm that by looking at the database.
postfixadmin=# SELECT username,password,modified FROM mailbox ORDER BY modified
DESC LIMIT 1;
That’s it
And I might want to check when everyone has migrated to the new password scheme to disable to postlogin script. I could use something like
postfixadmin=# SELECT username FROM mailbox WHERE password LIKE '{MD5-CRYPT%';
And when it shows no rows anymore, everyone has changed their password scheme
(probably by logging in).
And of course then I can remove the auth_allow_weak_schemes = yes option as
well.