From 8792aaa9a07647e167715684c0b1085e038008c4 Mon Sep 17 00:00:00 2001 From: Ville Takio Date: Tue, 27 Oct 2026 14:58:50 +0200 Subject: [PATCH 1/3] Unix: try cached passwords only with the KDF they opened a volume with When a volume is mounted with an empty password, every cached password is tried with the selected KDF, or with autodetection, which tries all KDFs including Argon2. Argon2 uses up to 1 GiB, and above PIM 31 its passes grow with the PIM, so with a large PIM every cached password that does not belong to the volume took minutes, as with favorites in #1815. Cache the KDF of the mounted volume together with the password, and with autodetection try each cached password only with that KDF. A password used with several KDFs gets an entry for each. If the KDF is unknown, autodetection is used as before. The Windows driver cache (src/Common/Cache.c) has the same problem (#1969) and is not changed here. --- src/Core/Unix/CoreServiceProxy.h | 19 ++++++++++++++++--- src/Volume/VolumePasswordCache.cpp | 24 ++++++++++++++++-------- src/Volume/VolumePasswordCache.h | 11 +++++++++-- 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/src/Core/Unix/CoreServiceProxy.h b/src/Core/Unix/CoreServiceProxy.h index 37f05c9a13..af4152bc72 100644 --- a/src/Core/Unix/CoreServiceProxy.h +++ b/src/Core/Unix/CoreServiceProxy.h @@ -118,11 +118,17 @@ namespace VeraCrypt finally_do_arg (MountOptions*, &options, { if (finally_arg->Password) finally_arg->Password.reset(); }); PasswordIncorrect passwordException; - foreach (shared_ptr password, VolumePasswordCache::GetPasswords()) + foreach (const CachedPassword &cached, VolumePasswordCache::GetPasswords()) { try { - newOptions.Password = password; + newOptions.Password = cached.Password; + // With autodetection, try a cached password only with the KDF it + // opened a volume with. Otherwise a password that does not match + // goes through every KDF, including Argon2, whose time and memory + // grow steeply with the PIM. + if (!options.Kdf) + newOptions.Kdf = cached.Kdf; mountedVolume = CoreService::RequestMountVolume (newOptions); break; } @@ -170,7 +176,14 @@ namespace VeraCrypt if (!useCachedPasswords && options.CachePassword && ((options.Password && !options.Password->IsEmpty()) || (options.Keyfiles && !options.Keyfiles->empty()))) { - VolumePasswordCache::Store (*newOptions.Password); + shared_ptr kdf; + try + { + kdf = Pkcs5Kdf::GetAlgorithm (mountedVolume->Pkcs5PrfName); + } + catch (ParameterIncorrect&) { } + + VolumePasswordCache::Store (*newOptions.Password, kdf); } VolumeEventArgs eventArgs (mountedVolume); diff --git a/src/Volume/VolumePasswordCache.cpp b/src/Volume/VolumePasswordCache.cpp index c9fb3bf5a9..2b1a5972d0 100644 --- a/src/Volume/VolumePasswordCache.cpp +++ b/src/Volume/VolumePasswordCache.cpp @@ -18,26 +18,34 @@ namespace VeraCrypt { CachedPasswordList passwords; - foreach_ref (const VolumePassword &password, CachedPasswords) - passwords.push_back (make_shared (VolumePassword (password))); + foreach (const CachedPassword &cached, CachedPasswords) + { + CachedPassword copy; + copy.Password = make_shared (VolumePassword (*cached.Password)); + copy.Kdf = cached.Kdf; + passwords.push_back (copy); + } return passwords; } - void VolumePasswordCache::Store (const VolumePassword &newPassword) + void VolumePasswordCache::Store (const VolumePassword &newPassword, shared_ptr kdf) { - CachedPasswordList::iterator iter = CachedPasswords.begin(); - foreach_ref (const VolumePassword &password, CachedPasswords) + // A password used with several KDFs gets an entry for each of them + for (CachedPasswordList::iterator iter = CachedPasswords.begin(); iter != CachedPasswords.end(); ++iter) { - if (newPassword == password) + if (*iter->Password == newPassword + && (iter->Kdf ? iter->Kdf->GetName() : wstring()) == (kdf ? kdf->GetName() : wstring())) { CachedPasswords.erase (iter); break; } - iter++; } - CachedPasswords.push_front (make_shared (VolumePassword (newPassword))); + CachedPassword cached; + cached.Password = make_shared (VolumePassword (newPassword)); + cached.Kdf = kdf; + CachedPasswords.push_front (cached); if (CachedPasswords.size() > Capacity) CachedPasswords.pop_back(); diff --git a/src/Volume/VolumePasswordCache.h b/src/Volume/VolumePasswordCache.h index 40a6a1c083..eaf1aca8fe 100644 --- a/src/Volume/VolumePasswordCache.h +++ b/src/Volume/VolumePasswordCache.h @@ -14,18 +14,25 @@ #define TC_HEADER_Volume_VolumePasswordCache #include "Platform/Platform.h" +#include "Pkcs5Kdf.h" #include "VolumePassword.h" namespace VeraCrypt { - typedef list < shared_ptr < VolumePassword > > CachedPasswordList; + struct CachedPassword + { + shared_ptr Password; + shared_ptr Kdf; // KDF that opened a volume with the password; null if unknown + }; + + typedef list CachedPasswordList; class VolumePasswordCache { public: static CachedPasswordList GetPasswords (); static bool IsEmpty () { return CachedPasswords.empty(); } - static void Store (const VolumePassword &newPassword); + static void Store (const VolumePassword &newPassword, shared_ptr kdf); static void Clear () { CachedPasswords.clear(); } static const size_t Capacity = 4; From 47bd299b51fc11252c88fd708864dd0ec74ced75 Mon Sep 17 00:00:00 2001 From: Ville Takio Date: Wed, 28 Oct 2026 23:32:43 +0200 Subject: [PATCH 2/3] Unix: order cached password attempts by cost instead of excluding KDFs Trying a cached password only with the KDF that it last opened a volume with stopped it from opening a volume that uses another KDF until the password was typed again, which matters most for keyfile contents. With an explicit KDF, an Argon2 derivation failure also ended the search instead of being skipped as in autodetection. With autodetection, each cached password is now tried with the KDF it opened a volume with, then with the other PBKDF2 KDFs, which are cheap. Argon2 is tried only for passwords that opened an Argon2 volume, or a volume whose KDF is unknown, through autodetection so that its handling of derivation failures is kept. Passwords associated with PBKDF2 come first, so an Argon2 association no longer delays them. If nothing matches, the user is asked for the password instead of trying Argon2 with every cached password: an Argon2 volume whose password is cached only from PBKDF2 volumes needs it once, and is then known as Argon2. --- src/Core/Unix/CoreServiceProxy.h | 67 ++++++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 8 deletions(-) diff --git a/src/Core/Unix/CoreServiceProxy.h b/src/Core/Unix/CoreServiceProxy.h index af4152bc72..f8e4ac22c7 100644 --- a/src/Core/Unix/CoreServiceProxy.h +++ b/src/Core/Unix/CoreServiceProxy.h @@ -117,18 +117,53 @@ namespace VeraCrypt { finally_do_arg (MountOptions*, &options, { if (finally_arg->Password) finally_arg->Password.reset(); }); + // A selected KDF is used with every cached password. With autodetection, + // each cached password is tried with the KDF it opened a volume with, + // then with the other PBKDF2 KDFs, which are cheap. Argon2, whose time + // and memory grow steeply with the PIM, is tried only for passwords that + // opened an Argon2 volume, or a volume with an unknown KDF: through + // autodetection, which also keeps its handling of derivation failures. + // If nothing matches, the user is asked for the password instead of + // trying Argon2 with every cached password. + CachedPasswordList cachedPasswords = VolumePasswordCache::GetPasswords(); + CachedAttemptList attempts; + + if (options.Kdf) + { + foreach (const CachedPassword &cached, cachedPasswords) + attempts.push_back (CachedAttempt (cached.Password, options.Kdf)); + } + else + { + foreach (const CachedPassword &cached, cachedPasswords) + { + if (cached.Kdf && !cached.Kdf->IsArgon2()) + AddCachedAttempt (attempts, cached.Password, cached.Kdf); + } + + foreach (const CachedPassword &cached, cachedPasswords) + { + foreach (shared_ptr kdf, Pkcs5Kdf::GetAvailableAlgorithms()) + { + if (!kdf->IsArgon2()) + AddCachedAttempt (attempts, cached.Password, kdf); + } + } + + foreach (const CachedPassword &cached, cachedPasswords) + { + if (!cached.Kdf || cached.Kdf->IsArgon2()) + AddCachedAttempt (attempts, cached.Password, shared_ptr ()); + } + } + PasswordIncorrect passwordException; - foreach (const CachedPassword &cached, VolumePasswordCache::GetPasswords()) + foreach (const CachedAttempt &attempt, attempts) { try { - newOptions.Password = cached.Password; - // With autodetection, try a cached password only with the KDF it - // opened a volume with. Otherwise a password that does not match - // goes through every KDF, including Argon2, whose time and memory - // grow steeply with the PIM. - if (!options.Kdf) - newOptions.Kdf = cached.Kdf; + newOptions.Password = attempt.first; + newOptions.Kdf = attempt.second; mountedVolume = CoreService::RequestMountVolume (newOptions); break; } @@ -206,6 +241,22 @@ namespace VeraCrypt { VolumePasswordCache::Clear(); } + + protected: + // A cached password with a KDF to try, or with autodetection if null + typedef pair < shared_ptr , shared_ptr > CachedAttempt; + typedef list CachedAttemptList; + + static void AddCachedAttempt (CachedAttemptList &attempts, shared_ptr password, shared_ptr kdf) + { + foreach (const CachedAttempt &attempt, attempts) + { + if (*attempt.first == *password && (attempt.second ? attempt.second->GetName() : wstring()) == (kdf ? kdf->GetName() : wstring())) + return; + } + + attempts.push_back (CachedAttempt (password, kdf)); + } }; } From d132aa3a5c2fbbb9240e1eb3e9c8cb9a70708482 Mon Sep 17 00:00:00 2001 From: Ville Takio Date: Wed, 28 Oct 2026 23:32:43 +0200 Subject: [PATCH 3/3] Tests: add a regression test for cached passwords and Argon2 Tests/test_password_cache_kdf.py mounts three favorites with password caching and a PIM of 100, and checks that a cached password that does not belong to a volume is not tried with Argon2, and that a matching cached password still mounts a volume without a prompt. --- .github/workflows/build-linux.yml | 3 + Tests/test_password_cache_kdf.py | 155 ++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 Tests/test_password_cache_kdf.py diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 6379b82ce8..11a6663efd 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -28,6 +28,7 @@ on: - 'Tests/test_stdin_password.py' - 'Tests/test_mount_helper.py' - 'Tests/test_linux_mount_point_protection.py' + - 'Tests/test_password_cache_kdf.py' - 'Tests/test.*.hc' - 'Tests/test_volume_read.py' - 'Tests/volume_read_test.cpp' @@ -64,6 +65,7 @@ on: - 'Tests/test_stdin_password.py' - 'Tests/test_mount_helper.py' - 'Tests/test_linux_mount_point_protection.py' + - 'Tests/test_password_cache_kdf.py' - 'Tests/test.*.hc' - 'Tests/test_volume_read.py' - 'Tests/volume_read_test.cpp' @@ -221,6 +223,7 @@ jobs: sudo python3 Tests/test_stdin_password.py --require-locales sudo python3 Tests/test_mount_helper.py --helper /usr/sbin/mount.veracrypt sudo python3 Tests/test_linux_mount_point_protection.py + sudo python3 Tests/test_password_cache_kdf.py sudo apt remove -y veracrypt - name: Install and test VeraCrypt Console .deb packages diff --git a/Tests/test_password_cache_kdf.py b/Tests/test_password_cache_kdf.py new file mode 100644 index 0000000000..6617134334 --- /dev/null +++ b/Tests/test_password_cache_kdf.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Regression test for issue #1969 on Linux, against the installed veracrypt. + +Usage (as root, after installing a package): + python3 Tests/test_password_cache_kdf.py [--veracrypt PATH] + +Creates three volumes with a PIM of 100: A (SHA-512) and C (SHA-256) share +a password, B (SHA-512) has another one. With password caching enabled, +mounts them as favorites in one run, typing only the passwords of A and B, +with KDF autodetection. Checked: + +- B is mounted after its password is typed, and the cached password of A + that is tried on B first fails without trying Argon2: with that PIM, + Argon2 uses 1 GiB and 82 passes, so the run would take much longer than + mounting the volumes one by one; +- C is mounted with the cached password of A, without a prompt, although + that password was cached with another KDF. +""" + +import argparse +import os +import shutil +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +PASSWORD_A = "regression-cache-password-a" +PASSWORD_B = "regression-cache-password-b" +PIM = "100" +# Argon2 with PIM 100 takes far longer than this on any machine that can run +# it, while the PBKDF2 attempts for the volumes here take a fraction of it. +MARGIN = 15 + + +class Checks: + def __init__(self): + self.failures = 0 + + def check(self, desc, cond, detail=""): + print(("ok " if cond else "FAIL ") + desc, flush=True) + if not cond: + self.failures += 1 + if detail: + print(" " + detail.strip().replace("\n", "\n ")) + + +def text(result): + return (result.stdout or b"").decode("utf-8", "replace") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--veracrypt", + default=shutil.which("veracrypt") or "veracrypt") + args = parser.parse_args() + if os.geteuid() != 0: + sys.exit("Run as root: the test mounts volumes") + + work = Path(tempfile.mkdtemp(prefix="vc-regression-")) + config = work / "config" / "VeraCrypt" + config.mkdir(parents=True) + env = dict(os.environ, XDG_CONFIG_HOME=str(work / "config")) + (work / "random").write_bytes(os.urandom(4096)) + c = Checks() + + def run(arguments, stdin=None): + return subprocess.run([args.veracrypt, "--text"] + arguments, + input=stdin, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, cwd=work, env=env, + timeout=1800) + + def mounted(): + """Paths of the mounted volumes; raises if they cannot be listed.""" + r = run(["--list"]) + if r.returncode != 0: + if "No volumes mounted" in text(r): + return set() + raise RuntimeError("veracrypt --list failed: " + text(r)) + return {line.split()[1] for line in text(r).splitlines() + if len(line.split()) >= 4 and line.split()[0].endswith(":")} + + def dismount(paths): + """Dismounts only these volumes; returns whether none remains.""" + paths = {str(path) for path in paths} + for path in paths & mounted(): + run(["--non-interactive", "-d", path]) + return not paths & mounted() + + mount_options = ["--pim=" + PIM, "--keyfiles=", "--protect-hidden=no", + "--filesystem=none", "--mount-options=nokernelcrypto"] + volumes = {name: work / ("%s.hc" % name) for name in ("a", "b", "c")} + try: + for name, password, hash in (("a", PASSWORD_A, "SHA-512"), + ("b", PASSWORD_B, "SHA-512"), + ("c", PASSWORD_A, "SHA-256")): + r = run(["--non-interactive", "--create", str(volumes[name]), + "--size=2M", "--volume-type=normal", "--encryption=AES", + "--hash=" + hash, "--filesystem=none", "-p", password, + "--pim=" + PIM, "--keyfiles=", + "--random-source=" + str(work / "random")]) + c.check("create %s.hc" % name, r.returncode == 0, text(r)) + if c.failures: + sys.exit("Cannot create the test volumes") + + start = time.monotonic() + r = run(["--non-interactive", "-p", PASSWORD_A, str(volumes["a"])] + + mount_options) + single = time.monotonic() - start + c.check("mount a.hc with KDF autodetection", + r.returncode == 0 and dismount([volumes["a"]]), text(r)) + + (config / "Configuration.xml").write_text( + '\n\n' + '\t\n\t\t1\n' + '\t\n\n') + (config / "Favorite Volumes.xml").write_text( + '\n\n' + '\t\n' + "".join( + '\t\t%s\n' % (slot, volumes[name]) + for slot, name in ((1, "a"), (2, "b"), (3, "c"))) + + '\t\n\n') + + start = time.monotonic() + # --load-preferences enables the password cache of Configuration.xml + r = run(["--load-preferences", "--auto-mount=favorites"] + + mount_options, + stdin=("%s\n%s\n" % (PASSWORD_A, PASSWORD_B)).encode()) + favorites = time.monotonic() - start + listed = mounted() + c.check("all three favorites are mounted", + r.returncode == 0 + and all(str(volumes[name]) in listed for name in volumes), + text(r) + "\n" + str(listed)) + c.check("c.hc (another KDF) is mounted from the cache, without a " + "prompt", + text(r).count("Enter password") == 2, text(r)) + c.check("cached passwords that do not match are not tried with " + "Argon2 (%.1f s, one mount %.1f s)" % (favorites, single), + favorites < 3 * single + MARGIN) + finally: + # Only the volumes of this test: unrelated ones may be mounted + if not dismount(volumes.values()): + sys.exit("A test volume is still mounted; not removing %s" % work) + shutil.rmtree(work, ignore_errors=True) + + if c.failures: + sys.exit("%d check(s) failed" % c.failures) + print("All checks passed") + + +if __name__ == "__main__": + main()