{
"$type": "site.standard.document",
"bskyPostRef": {
"cid": "bafyreigx4gr4mkcy6yvyw7ynxlihzuktjikdlgau62xdb42rhi5hpzybtm",
"uri": "at://did:plc:46ti67tc37qcmwp2vaynk6fq/app.bsky.feed.post/3mi5tjfbjtyv2"
},
"path": "/2026/03/converting-dovecot-password-schemes-on-the-fly-without-too-much-cursing/",
"publishedAt": "2026-03-28T23:56:44.995Z",
"site": "https://www.die-welt.net",
"tags": [
"Dovecot Configuration Upgrader",
"userdb",
"Converting Password Schemes",
"I know Python",
"execute a post-login script",
"passlib",
"mysqlclient",
"execv"
],
"textContent": "I finally upgraded my mail server to Debian 13 and, as expected, the Dovecot part was quite a ride.\n\nThe configuration syntax changed between Dovecot 2.3 (Debian 12) and Dovecot 2.4 (Debian 13), so I started first with diffing my configuration against a vanilla Debian 12 one (this setup is slightly old) and then applied the same (logical) changes to a vanilla Debian 13 one. This mostly went well. Mostly because my user database is stored in SQL and while the Dovecot Configuration Upgrader says it can convert old `dovecot-auth-sql.conf.ext` files to the new syntax, it only does so for the structure, not the SQL queries themselves. While I don't expect it to be able to parse the queries and adopt them correctly, at least a hint that the field names in userdb changed and might require adjustment would've been cool.\n\nOnce I got that all sorted, Dovecot would still refuse to let me in:\n\n\n Error: sql: Invalid password in passdb: Weak password scheme 'MD5-CRYPT' used and refused\n\n\nYeah, right. Did I mention that this setup is old?\n\nThe quick cure against this is a `auth_allow_weak_schemes = yes` in `/etc/dovecot/conf.d/10-auth.conf`, but long term I really should upgrade the password hashes in the database to something more modern.\n\nAnd this is what this post is about.\n\nMy database only contains hashed (and salted) passwords, so I can't just update them without changing the password. And while there are only 9 users in total, I wanted to play nice and professional. (LOL)\n\nThere is a Converting Password Schemes howto in the Dovecot documentation, but it uses a rather odd looking PHP script, wrapped in a shell script which leaks the plaintext password to the process list, and I really didn't want to remember how to write PHP to complete this task.\n\nLuckily, I know Python.\n\nThe general idea is:\n\n * As we're using plaintext authentication (`auth_mechanisms = plain login`), the plaintext password is available during login.\n * After Dovecot's `imap-login` has verified the password against the old (insecure) hash in the database, we can execute a post-login script, which will connect to the database and update it with a new hash of the plaintext password.\n\n\n\nTo make the plaintext password available to the post-login script, we add `'%{password}' as userdb_plain_pass` to the `SELECT` statement of our `passdb` query. The original howto also says to add a `prefetch` `userdb`, which we do. The `sql` `userdb` remains, as otherwise Postfix can't use Dovecot to deliver mail.\n\nNow comes the interesting part. We need to write a script that is executed by Dovecot's `script-login` and that will update the database for us. Thanks to Python's passlib and mysqlclient, the database and hashing parts are relatively straight forward:\n\n\n #!/usr/bin/env python3\n\n import os\n\n import MySQLdb\n import passlib.hash\n\n DB_SETTINGS = {\"host\": \"127.0.0.1\", \"user\": \"user\", \"password\": \"password\", \"database\": \"mail\"}\n SELECT_QUERY = \"SELECT password_enc FROM mail_users WHERE username=%(username)s\"\n UPDATE_QUERY = \"UPDATE mail_users SET password_enc=%(pwhash)s WHERE username=%(username)s\"\n\n SCHEME = \"bcrypt\"\n EXPECTED_PREFIX = \"$2b$\"\n\n\n def main():\n # https://doc.dovecot.org/2.4.3/core/config/post_login_scripting.html\n # https://doc.dovecot.org/2.4.3/howto/convert_password_schemes.html\n user = os.environ.get(\"USER\")\n\n plain_pass = os.environ.get(\"PLAIN_PASS\")\n if plain_pass is not None:\n db = MySQLdb.connect(**DB_SETTINGS)\n cursor = db.cursor()\n cursor.execute(SELECT_QUERY, {\"username\": user})\n result = cursor.fetchone()\n current_pwhash = result[0]\n\n if not current_pwhash.startswith(EXPECTED_PREFIX):\n hash_module = getattr(passlib.hash, SCHEME)\n pwhash = hash_module.hash(plain_pass)\n data = {\"pwhash\": pwhash, \"username\": user}\n cursor.execute(UPDATE_QUERY, data)\n cursor.close()\n db.close()\n\n\n if __name__ == \"__main__\":\n main()\n\n\nBut if we add that as `executable = script-login /etc/dovecot/dpsu.py` to our `imap-postlogin` `service`, as the howto suggests, the users won't be able to login anymore:\n\n\n Error: Post-login script denied access to user\n\n\nWAT?\n\nRemember that shell script I wanted to avoid? It ends with `exec \"$@\"`.\n\nTurns out the `script-login` \"API\" is rather interesting. It's not \"pass in a list of scripts to call and I'll call all of them\". It's \"pass a list of scripts, I'll execv the first item and pass the rest as args, and every item is expected to `execv` the next one again\". 🤯\n\nWith that (cursed) knowledge, the script becomes:\n\n\n #!/usr/bin/env python3\n\n import os\n import sys\n\n import MySQLdb\n import passlib.hash\n\n DB_SETTINGS = {\"host\": \"127.0.0.1\", \"user\": \"user\", \"password\": \"password\", \"database\": \"mail\"}\n SELECT_QUERY = \"SELECT password_enc FROM mail_users WHERE username=%(username)s\"\n UPDATE_QUERY = \"UPDATE mail_users SET password_enc=%(pwhash)s WHERE username=%(username)s\"\n\n SCHEME = \"bcrypt\"\n EXPECTED_PREFIX = \"$2b$\"\n\n\n def main():\n # https://doc.dovecot.org/2.4.3/core/config/post_login_scripting.html\n # https://doc.dovecot.org/2.4.3/howto/convert_password_schemes.html\n user = os.environ.get(\"USER\")\n\n plain_pass = os.environ.get(\"PLAIN_PASS\")\n if plain_pass is not None:\n db = MySQLdb.connect(**DB_SETTINGS)\n cursor = db.cursor()\n cursor.execute(SELECT_QUERY, {\"username\": user})\n result = cursor.fetchone()\n current_pwhash = result[0]\n\n if not current_pwhash.startswith(EXPECTED_PREFIX):\n hash_module = getattr(passlib.hash, SCHEME)\n pwhash = hash_module.hash(plain_pass)\n data = {\"pwhash\": pwhash, \"username\": user}\n cursor.execute(UPDATE_QUERY, data)\n cursor.close()\n db.close()\n\n os.execv(sys.argv[1], sys.argv[1:])\n\n\n if __name__ == \"__main__\":\n main()\n\n\nAnd the passwords are getting gradually updated as the users log in. Once all are updated, we can remove the post-login script and drop the `auth_allow_weak_schemes = yes`.",
"title": "Evgeni Golov: Converting Dovecot password schemes on the fly without (too much) cursing",
"updatedAt": "2026-03-28T22:11:57.000Z"
}