Skip to content

Security

Key derivation (Argon2d, AES-KDF), saving, and memory protection in KeePass Core.

The key derivation function (KDF) turns the master key (password, key file, ...) into the key that decrypts the database. It is deliberately slow and expensive, so that an attacker who steals the file has to pay that price for every password guess. This page explains what was reviewed in KeePass Core, what changed, why, and what it costs.

Three rules guided every decision:

  • Never break KDBX 4.x databases. Files written by KeePass Core must still open in KeePassXC, KeePassDX, Strongbox, KeePass 2.x, ... (see the KDBX specification).
  • Prefer the stronger, modern option wherever the format allows it.
  • One managed code path on every OS: no native library to ship or to find at run time, and cancellation and memory wiping stay under our control.

Summary of the changes:

  • New databases use Argon2d by default (was AES-KDF), with parameters that comply with RFC 9106.
  • Argon2 is managed only: the native libargon2 path was removed. Its memory is always wiped. CancellationToken allow to cancel the process.
  • AES-KDF can be cancelled: fixes a denial of service where a crafted file could freeze the application forever.
  • AES-KDF runs the managed code on every OS: the hidden libgcrypt path (Linux) and the KeePassLibC path (Windows) were removed.
  • AES-KDF is reported as weak: the applications offer Argon2d instead.
  • Passwords in memory are encrypted with KeePassCore.Security.Memory (AES-256-GCM, key in OS-protected memory), and never kept as plain text after a read.
  • Both KDFs are covered by known-answer tests from independent reference implementations.
  • A failed save never loses the database.
  • Saved files are always KDBX 4, with the ChaCha20 inner stream (no more Salsa20 on save).
  • WinForms app: triggers, URL overrides and password generator profiles can run commands, so they are only used from the enforced configuration file next to the executable, as in KeePass 2.54 (CVE-2023-24055); those found in the user's file are moved to it when it is writable, otherwise disabled. The help URL and the plugin cache path are also only used when enforced.

The scope is the one of KeePass 2.x (see the KeePass security model): malware running as the same user and an attacker with administrator or root rights are out of scope.

Argon2d

Why Argon2d by default

AES-KDF, the former default, only costs CPU time. Graphics cards and dedicated hardware compute it very cheaply and massively in parallel. Argon2 (winner of the Password Hashing Competition, standardised as RFC 9106) is memory-hard: every guess also needs a large amount of memory, which is what makes massive parallel cracking expensive.

KDBX 4 supports two variants. Argon2d gives the strongest resistance to GPU/ASIC cracking of a stolen file, which is the main threat for a password database, and it is also the default of KeePassXC. Argon2id adds protection against side-channel attacks by a process watching the same machine, which is outside the KeePass threat model (malware running as the same user can read the database anyway). Argon2id stays available.

Default parameters for new databases: 64 MiB of memory, 2 iterations, parallelism 2. A unit test covers the defaults of a new database, so a change cannot go unnoticed.

RFC 9106 minimum memory

RFC 9106 requires at least 8 KiB of memory per lane (8 KiB × parallelism) (see here). The reference library libargon2, and therefore KeePassXC and pykeepass, refuse files below that minimum (here), while old KeePass 2.x versions could write them.

Decision: such parameters are rejected on write only. The check runs before the file is opened for writing (without file transactions, opening it truncates it), for saves and exports. Old files still open, so no user is locked out; they just cannot be saved as is. An explicit migration step (raise the memory, then save) is planned in the application.

Verification of the implementation

The managed Argon2 code was reviewed and is covered by tests:

  • RFC 9106 test vectors, and bit-exact results with libargon2 on 161 parameter sets (parallelism up to 255).
  • Cancellation checked per slice and every 64 blocks of each segment.
  • The working memory, the initial hash and the scratch blocks of each worker are wiped on every path, including errors and cancellation. The BLAKE2b state is wiped on dispose.
  • The working memory is outside the managed heap (see below).
  • An error in a worker thread is propagated instead of being lost.

Working memory outside the managed heap

The Argon2 working memory (64 MiB by default) holds data derived from the master key. As a .NET array, the garbage collector could move it and leave copies behind that were never wiped, and it could be swapped out or written to a core dump. It now lives in unmanaged memory from KeePassCore.Security.Memory: never moved, locked in RAM and excluded from core dumps where the operating system allows it, and wiped when released. The small scratch blocks of each worker are on the thread stack. The forced garbage collection after a large key derivation is no longer needed. Measured on the same machine, the key derivation is not slower, rather a bit faster.

Removal of the native libargon2 path

Inherited from KeePass 2.x, a native path called libargon2 when available. It was removed:

  • A native call cannot be cancelled: a long key derivation could not be stopped.
  • Its working memory is handled by libargon2, out of reach of our wiping code.
  • It did not support every parameter (secret key, associated data) and silently fell back to the managed code.
  • On Linux and macOS it only ran behind a Mono workaround that is never enabled on .NET: it was dead code.

The cost was measured before the removal: the managed code is within noise of libargon2 (see benchmarks).

AES-KDF

How it works, and why it stays

AES-KDF encrypts each 16-byte half of the key N times with AES-256 (the key being a random 32-byte seed from the file header), then hashes the result with SHA-256. The default is 600,000 rounds.

It is not memory-hard, so it is no longer the default. It cannot be removed either: many existing KDBX 4 databases use it and must keep opening.

Decision: AES-KDF is kept, and always reported as weak, whatever the number of rounds (before, only below 600,000 rounds). When a database using it is opened, the WinForms application offers to switch to Argon2d with the default parameters (this can be declined, and the question turned off), and the terminal application shows a warning.

Denial of service fixed

The number of rounds is a 64-bit value read from the file header, with no upper bound. The cancellation was only checked before the rounds started, never during them. A crafted file asking for 264 − 1 rounds therefore froze the application with no way to stop it.

The cancellation is now checked every 8,192 rounds. This does not change the result. Measured: cancelling such a derivation stops it in 12 ms.

Rounds checked on save

KeePassXC reads the number of rounds as a 32-bit integer and only accepts 1 to 231 − 2. A save with rounds outside that range, or with 0 rounds (no key stretching at all), is now refused before the file is touched, like the Argon2 minimum memory. Such files still open. The seed is not checked: a new random 32-byte seed is generated at every save.

Known-answer tests

AES-KDF used to be checked only against itself (managed vs native). It is now compared with values computed by OpenSSL 3.6, independently of our code, for 0, 1, 2, 8,191, 8,192, 8,193 (around the internal buffer size) and 600,000 rounds.

The reference uses a property of the CBC mode: encrypting a block N times with AES-ECB gives the same value as the last block of AES-CBC over N zero blocks, with the block as IV. So one OpenSSL command per half gives the expected value, and the property itself was cross-checked with an explicit ECB loop.

Hidden libgcrypt path removed

While writing these tests, it turned out that on Linux AES-KDF did not run the managed code at all: it went through libgcrypt, because of a Mono workaround that is always considered active on .NET (see Mono legacy). That path gave the right result, but it bypassed the "managed code only" setting and it caused a long-standing self-test failure in debug builds. It was removed: the benchmark below shows that it brought 1 to 3 ms per unlock.

The last native path, the KeePassLibC DLL of KeePass 2.x on Windows, was removed too: it could not be cancelled, and KeePass Core does not ship that DLL anyway. AES-KDF now always runs the managed code.

Benchmarks

Each managed KDF is compared with the native reference library on the same parameters. For every configuration the benchmark also checks that both give the same output: they always did.

Machine: Intel Core i7-1355U laptop (2 performance + 8 efficiency cores) on AC power, CachyOS Linux, .NET 10, Release build. Median of 9 runs, order alternated between runs. libargon2: Debian build (SSE2, threads, no AVX2). libgcrypt 1.12.4, called directly. Measured on 2026-09-27.

Argon2: managed vs libargon2

Configuration Managed libargon2 Ratio Ratio over 3 runs
Argon2d 64 MiB, t=2, p=2 (default) 68 ms 63 ms 1.08 1.04 - 1.08
Argon2d 256 MiB, t=2, p=4 176 ms 153 ms 1.15 1.03 - 1.17
Argon2d 1 GiB, t=2, p=8 476 ms 491 ms 0.97 0.97 - 1.13
Argon2id 64 MiB, t=2, p=2 67 ms 78 ms 0.85 0.85 - 0.98

The managed Argon2 is within noise of libargon2: 3 to 5 ms per unlock at the default. The large configurations vary between runs (hybrid cores, turbo, heat). The managed code does not use SIMD yet, so there is room left. Note: on battery power the same laptop measured the managed code 8 to 20 % slower (114 vs 105 ms at the default). The ratio depends on the power state, so benchmarks should be run on AC power.

AES-KDF: managed vs libgcrypt

Rounds Managed libgcrypt Ratio Ratio over 2 runs
600,000 (default) 9 ms 6 ms 1.36 1.11 - 1.36
10,000,000 114 ms 104 ms 1.09 1.08 - 1.09
60,000,000 670 ms 607 ms 1.10 1.06 - 1.10

The second run used 3 samples instead of 9. Both use the processor's AES instructions (AES-NI): .NET goes through OpenSSL on Linux and CNG on Windows. The managed code is 6 to 10 % slower on long runs, which is 1 to 3 ms per unlock at the default. Through the former libgcrypt wrapper of the library, the native side took 9 ms instead of 6 ms: part of the gap was the wrapper, not AES. Moving to the span-based .NET API may close the rest. Keeping a native library is not worth it.

Reproduce

The benchmark will be part of the repository. It prints the test conditions and Markdown tables:

Saving

Saved files are always KDBX 4. The KDBX 3.1 write path was removed: its inner stream cipher (Salsa20, protecting passwords inside the file) is no longer written, saved files use ChaCha20, and the KDBX 3.1 header hash inside the XML is gone (KDBX 4 authenticates the header with an HMAC). KDBX 3.1 files still open, until the migration tool exists.

Before, a save outside Windows transactional NTFS deleted the database, then renamed the temporary file in its place: if the rename failed, the database was lost. A save now writes a temporary file in the same folder, then replaces the database in one atomic step. Access rights, creation time, Unix permissions (such as 0600) and the hidden attribute are kept. Windows transactional NTFS, deprecated by Microsoft, is no longer used.

Remote files (HTTP, FTP) still use delete then rename, until the network code is rewritten.

Memory protection

Passwords and other protected fields are kept encrypted in memory while the database is open, as in KeePass 2.x:

  • They use KeePassCore.Security.Memory, a separate library (see its roadmap): AES-256-GCM on every OS, with a random key per process kept in memory that the OS protects: memfd_secret or locked memory excluded from core dumps on Linux, locked memory and CryptProtectMemory on Windows, locked memory on macOS. Only the encrypted form lives in the managed heap.
  • Before: ChaCha20 with a key in normal managed memory (readable in a memory dump), and a disabled Windows-only ProtectedMemory (DPAPI) path. Both removed, as well as the plugin hook that could replace the encryption.
  • Reading a protected value is also faster: about 2.5 µs instead of 11 µs.
  • A protected text never keeps a plain copy: KeePass 2.x kept the text it was created from (typed in the UI) and, after the first read, dropped the encrypted copy for good. Each read now decrypts again (about 2.6 µs). Remaining limit, as in KeePass 2.x: a plain string handed to the UI (a text box) cannot be wiped; it is only reclaimed by the garbage collector.

Known limit: the Windows user account key (an optional part of the master key) uses DPAPI, so it is not available outside Windows. Whether to support it elsewhere (macOS Keychain, Linux Secret Service) or to point to key files is still to be decided.

Mono legacy

KeePass 2.x runs on Linux and macOS through Mono, and carries workarounds for Mono bugs. On .NET 10 several of them misbehaved, which is why all of them were removed:

  • The workarounds were enabled by "running on Unix" and by the Mono version. On .NET the Mono version is 0, so every workaround was considered required.
  • Workaround 1468 ("Mono's AES is slow, use libgcrypt") therefore routed AES-KDF to libgcrypt on Linux, ignoring the "managed code only" setting.
  • Reading the Mono version triggered a debug assertion on Unix. It killed the terminal application and made the built-in self-test fail in debug builds.
  • Workaround 100004 (native Argon2 on Unix) was never enabled, so that code never ran.

Next steps

  • Argon2: SIMD in the block computation, to match or beat libargon2.
  • Migration step for old files whose Argon2 memory is below the RFC 9106 minimum.
  • Windows user account key: decide how it works outside Windows.