ADR-0057: Pluggable password hasher with a pure-Elixir option, and a dev container
| Status | Accepted |
| Date | 2026-10-13 |
| Issue | #53 |
| Pull request | #159 |
| Related | ADR-0021, ADR-0023 |
Context and problem statement
On a fresh Windows machine, mix deps.get && mix compile stops at the first NIF that needs a C toolchain:
==> bcrypt_elixir
** (Mix) "nmake" not found in the path.
bcrypt_elixir hashes the passwords of admin and tenant users. Its NIF is built with MSVC and nmake on Windows,
so a contributor needs the Visual Studio Build Tools and a Developer shell before running a single test. The
workaround was documented, but it is heavy for someone who only wants to run the test suite. bcrypt_elixir is the
only dependency that blocks the build: brod's crc32cer NIF does not build on Windows either, but it is loaded only
when a Kafka channel runs, and the compile goes through without it (ADR-0047).
The schemas called Bcrypt directly (AdminUser, TenantUser), so there was no way to choose another algorithm.
Decision drivers
- A contributor on any OS can build and test without installing a C compiler.
- Production hashing does not change: bcrypt stays the default, and existing hashes keep verifying.
- No compile-time warnings or code paths that differ between the two builds beyond the hasher.
- One switch that controls both the dependency set and the runtime choice.
Considered options
- Document the MSVC setup only. Already done; does not remove the requirement.
- Switch to
argon2_elixir. Also a C NIF with the same Windows problem. - A hasher behind a facade, with
pbkdf2_elixiras a pure-Elixir option chosen by an environment variable that also dropsbcrypt_elixirfrom the deps. - Replace bcrypt with PBKDF2 everywhere. Removes the NIF but changes production hashing and forces a rehash.
Plus, independent of the hasher: a dev container (.devcontainer/) with the pinned toolchain, the C tools and
Postgres, so every OS gets a Linux environment that matches CI.
Decision
Chosen option: 3, plus the dev container.
Converger.Accounts.PasswordHasherhashes new passwords with the module inconfig :converger, :password_hasher(anyComeoninimplementation;BcryptorPbkdf2).AdminUserandTenantUsercall it instead ofBcrypt.- Verification follows the stored hash, not the setting:
$2…hashes go toBcrypt,$pbkdf2-…hashes toPbkdf2. Both kinds can sit in the same table, so switching the setting never locks out existing users while both libraries are compiled. If a hash needs a library that is not compiled in, the login fails after a dummy verification (same timing as an unknown user) and a warning is logged. CONVERGER_PASSWORD_HASHER(bcryptby default, orpbkdf2) is read inmix.exsand inconfig/config.exs. Withpbkdf2,bcrypt_elixiris not a dependency at all, so nothing needs a C compiler, and new hashes are PBKDF2-SHA512 (pbkdf2_elixirdefaults: 160,000 iterations, 16-byte salt). Any other value stopsmixwith an error.pbkdf2_elixiris always a dependency. It is pure Elixir on top of:crypto, it keepsmix.lockthe same in both modes (CI'sdeps.unlock --check-unusedsees one lock), and it lets a bcrypt build verify PBKDF2 hashes made in a pbkdf2 build that shares the database..devcontainer/(VS Code Dev Containers, GitHub Codespaces, thedevcontainerCLI): thehexpm/elixirimage of the rootDockerfilewith build-essential, cmake and inotify-tools, a non-rootvscodeuser, and a Postgres 17 service with the dev/test defaults.deps/and the build root (MIX_DEPS_PATH,MIX_BUILD_ROOT) live in named volumes, not in the bind-mounted checkout, so Linux artifacts never mix with a host's Windows or macOS_build.
Consequences
Positive
- A Windows contributor runs
$env:CONVERGER_PASSWORD_HASHER = "pbkdf2"; mix setup; mix testin a plain PowerShell window. The whole suite passes in that mode. - Production behavior and stored hashes are unchanged; the release image still builds bcrypt.
- The dev container gives macOS, Linux and Windows the same environment as CI, including the Kafka NIF.
- Moving to another
Comeoninhasher later is a config change plus a prefix clause.
Negative and trade-offs
- Two builds exist. The pbkdf2 build cannot verify bcrypt hashes, so a database seeded by a bcrypt build has to be reseeded (or the passwords reset) before logging in from a pbkdf2 build. Tests are unaffected: they create their users.
- The variable is read at compile time. Changing it needs
mix deps.getand recompiles the project (config.exschanges); a separateMIX_BUILD_PATHper mode avoids recompiling back and forth. - Nothing stops a production build with
CONVERGER_PASSWORD_HASHER=pbkdf2. PBKDF2-SHA512 at 160,000 iterations is an accepted password hash, but bcrypt is the documented and tested production setting. - One more dependency (
pbkdf2_elixir, sharingcomeoninwithbcrypt_elixir).
Follow-ups
- Rehash on login when a stored hash is not the configured algorithm, if a deployment ever switches algorithms for real. Not needed while the second hasher is a development aid.
Implementation
lib/converger/accounts/password_hasher.ex: the facade;admin_user.exandtenant_user.exuse it.mix.exspassword_hasher_deps/0;config/config.exs:password_hasher;config/test.exssets one PBKDF2 round, as it already did for bcrypt (ADR-0023).test/converger/accounts/password_hasher_test.exs: hashing with each configured module, cross-verification, and the bcrypt-not-compiled path (that test is compiled only in a pbkdf2 build)..devcontainer/:devcontainer.json,compose.yml,Dockerfile.- Verified: the full suite on Windows with
CONVERGER_PASSWORD_HASHER=pbkdf2and no MSVC onPATH, and the full suite inside the dev container (built with thedevcontainerCLI).