Is it possible to set pre-hashed secrets for account passwords?

I’m planning a migration from an existing IMAP setup, and I’m trying to minimise user disruption by keeping their existing usernames and passwords. All the usernames are fine as they’re [email protected] which fits in with Stalwart’s schema, but the passwords are hashed. Is it possible to set the password with this pre-hashed value?

The passwords doc says that Stalwart recognises a number of different formats, but can the password be set in one of those formats directly?

I’ve tried running:

stalwart-cli update account c --field credentials/0/secret='$1$test$2uZnGZGO4rM51ZnqzUT7f/' -k

but then the password gets set to exactly that value. Similarly with prepending {CRYPT} to the hash.

I can see that an external directory might work, but I’d prefer to use a “vanilla” Stalwart setup without this complication. It is a small personal setup.

Also let’s check how hash_secret is called when setting credentials via CLI to understand the issue:

Based on a careful look at the source code, here is what is actually happening and what your options are:

The short answer: the admin principal API always re-hashes whatever you write into credentials/0/secret. Looking at crates/jmap/src/registry/mapping/principal.rs (line 300) and the bootstrap code, every time a credential secret is written via the admin path, hash_secret() is called on the raw value before it is stored. That is why your MD5-crypt hash is being stored as a literal string — it gets hashed again (as if it were a plaintext password), rather than being recognised as an already-hashed value.

However, looking at crates/directory/src/core/secret.rs, the verification side (verify_secret_hash) does correctly detect and handle a large number of hash prefixes at login time:

Prefix Scheme
$argon2… Argon2
$pbkdf2… PBKDF2
$scrypt… scrypt
$2… bcrypt
$6$… SHA-512 crypt
$5$… SHA-256 crypt
$1… MD5 crypt
_… BSDi DES crypt
{SHA}, {SSHA}, {SHA256}, {SSHA256}, {SHA512}, {SSHA512}, {MD5}, {CRYPT}, {PLAIN} LDAP-style prefixed hashes

The problem is that the admin write path calls hash_secret() unconditionally, so there is currently no supported way to bypass hashing and inject a pre-existing hash string via stalwart-cli or the admin API for internal-directory accounts.

Your realistic options for migration:

  1. Force a password reset for all users — migrate accounts with a temporary random password and have users reset on first login.
  2. Use an external SQL or LDAP directory temporarily during migration, where the columnSecret / attrSecret is read directly (not re-hashed), and migrate users across gradually.
  3. Open a feature request on the Stalwart GitHub asking for an “import pre-hashed password” flag in the admin API — the verification code already supports all the formats, the gap is only on the write path.

This isn’t documented as a known limitation in the current docs, so a community member or staff member may be able to confirm whether a workaround exists that isn’t covered here.

This is an automated reply from the Stalwart Help Bot. Other community members may follow up if this answer is incomplete or wrong. If you found it useful, mention @helpbot in any reply to ask follow-up questions and the bot will respond again.

The bot is correct, you can’t set a hashed password directly unless you use an external directory.

@helpbot Would it be possible to migrate the users with a random password and set the hashed password afterwards in the user database somehow?

@helpbot the used to be a database cli. Does the v0.16 allow manipulation of the credentials directly in the database?

@svenihoney Stalwart only accepts hashed passwords in bootstrap or recovery mode. Otherwise you need to set passwords in clear text over a TLS connection which are then encrypted by Stalwart.

@patch Only if you use an external directory. Otherwise it is stores serialized inside the account object.

Since the great stalwart is open source, I helped myself by simply commenting the hashing during the migration:

diff --git a/crates/jmap/src/registry/mapping/principal.rs b/crates/jmap/src/registry/mapping/principal.rs

index 5bab6856..ce9b9083 100644

— a/crates/jmap/src/registry/mapping/principal.rs

+++ b/crates/jmap/src/registry/mapping/principal.rs

@@ -297,12 +297,13 @@

async fn validate_credential_creation(
Some(UTCDateTime::from_timestamp((now() + expires_at) as i64));
}

-                credential.secret = hash_secret(

-                    server.core.network.security.password_hash_algorithm,

-                    std::mem::take(&mut credential.secret).into_bytes(),

-                )

-                .await

-                .caused_by(trc::location!())?;

+                // credential.secret = hash_secret(

+                //     server.core.network.security.password_hash_algorithm,

+                //     std::mem::take(&mut credential.secret).into_bytes(),

+                // )

+                // .await

+                // .caused_by(trc::location!())?;

             Ok(Ok(()))
         }
     }

stalwart-cli apply migrated my passwords flawlessly. After the migration the unpatched executable took over. Would be nice if this codeblock could be made optional e.g. by an option in config.json.

Added.

Thank you so much! A tricky piece to get right :slight_smile: