Tested against main (160b096).
What happens
vault.rs:189 is a plain synchronous Argon2id call:
fn derive_key(passphrase: &str, salt: &[u8]) -> Result<[u8; 32]> {
let params = Params::new(64 * 1024, 3, 4, Some(32)) // 64 MiB, t=3, p=4
Every call site is inside an async fn, and none of them offloads it:
grep -rn "spawn_blocking\|block_in_place\|block_on" core/src desktop/src-tauri/src returns three hits (biometric.rs:13, biometric_commands.rs:31, vault_keychain_cleanup.rs:260) and all three wrap Keychain calls. The KDF is never wrapped, so each derivation parks a tokio worker for its whole duration.
Nothing caches the result either, so add_key (vault.rs:113-124) and remove_key (:127-140) each run it twice — once to open the payload, once to re-seal it.
Measured
AMD Ryzen 5 9600X (6C/12T), Windows 11, release profile (opt-level = "s", lto = true — the profile the app ships with). Medians.
|
before |
after |
| one derivation, production params |
102 ms |
107 ms (unchanged work, now on a blocking thread) |
get_key on the SSH connect path |
109 ms |
0.003 ms |
add_key / remove_key |
2 derivations ≈ 205 ms |
1.5 ms |
| unrelated command queued behind a derivation on the same worker |
107 ms |
0.0 ms |
That last row is a direct measurement, not an inference: a 1-worker tokio runtime, one task running the KDF, a second task spawned right after that records how long it waited before its first poll. Inline it waits out the whole derivation; via spawn_blocking it runs immediately. Whether an end user perceives those 107 ms as a UI freeze depends on how many workers the Tauri runtime has and what else is queued — that part I did not measure, and I am not claiming it.
Debug builds are ~12x slower (1.20 s per derivation), which is worth knowing when profiling from tauri dev.
p=4 buys nothing here
argon2 is pulled without any parallelism support — 0.5.3 has no such feature at all:
$ cargo tree -p clavyn-core -e features -i argon2
argon2 v0.5.3
├── argon2 feature "alloc" ...
# available features: alloc, default, password-hash, rand, simple, std, zeroize
Measured, 7 runs each, same machine and profile: m=64MiB t=3 p=4 → 102.3 ms median; m=64MiB t=3 p=1 → 100.0 ms median. The four lanes are computed sequentially, so the defender pays the same wall clock either way while an attacker with four cores gets a 4x head start.
I did not change this, deliberately. The two ways to fix it are:
- upgrade
argon2 to a release with rayon-backed lanes — output-identical, so existing vaults keep opening, but it is a dependency bump that wants its own review; or
- set
p=1 and spend the budget on m_cost — strictly better cryptographically, but it changes every derived key, so it needs a versioned vault re-key migration or every existing vault becomes undecryptable.
Neither belongs in a latency fix. Filing it here so it is not lost. The current parameters (64 MiB, t=3) stay well above the OWASP 2024 floor of 19 MiB / t=2 in the meantime.
Suggested fix
- Make the KDF reachable only through
async wrappers on Vault that spawn_blocking, so no synchronous path can reach it by accident.
- Cache the derived key for the unlocked session and invalidate it wherever the passphrase is invalidated.
PR to follow.
Tested against
main(160b096).What happens
vault.rs:189is a plain synchronous Argon2id call:Every call site is inside an
async fn, and none of them offloads it:vault_commands.rs:42(unlock) and:92(reset)vault_initialize.rs:52biometric.rs:186,biometric_commands.rs:231connection.rs:108— on every SSH connect that uses key authgrep -rn "spawn_blocking\|block_in_place\|block_on" core/src desktop/src-tauri/srcreturns three hits (biometric.rs:13,biometric_commands.rs:31,vault_keychain_cleanup.rs:260) and all three wrap Keychain calls. The KDF is never wrapped, so each derivation parks a tokio worker for its whole duration.Nothing caches the result either, so
add_key(vault.rs:113-124) andremove_key(:127-140) each run it twice — once to open the payload, once to re-seal it.Measured
AMD Ryzen 5 9600X (6C/12T), Windows 11, release profile (
opt-level = "s",lto = true— the profile the app ships with). Medians.get_keyon the SSH connect pathadd_key/remove_keyThat last row is a direct measurement, not an inference: a 1-worker tokio runtime, one task running the KDF, a second task spawned right after that records how long it waited before its first poll. Inline it waits out the whole derivation; via
spawn_blockingit runs immediately. Whether an end user perceives those 107 ms as a UI freeze depends on how many workers the Tauri runtime has and what else is queued — that part I did not measure, and I am not claiming it.Debug builds are ~12x slower (1.20 s per derivation), which is worth knowing when profiling from
tauri dev.p=4buys nothing hereargon2is pulled without any parallelism support — 0.5.3 has no such feature at all:Measured, 7 runs each, same machine and profile:
m=64MiB t=3 p=4→ 102.3 ms median;m=64MiB t=3 p=1→ 100.0 ms median. The four lanes are computed sequentially, so the defender pays the same wall clock either way while an attacker with four cores gets a 4x head start.I did not change this, deliberately. The two ways to fix it are:
argon2to a release with rayon-backed lanes — output-identical, so existing vaults keep opening, but it is a dependency bump that wants its own review; orp=1and spend the budget onm_cost— strictly better cryptographically, but it changes every derived key, so it needs a versioned vault re-key migration or every existing vault becomes undecryptable.Neither belongs in a latency fix. Filing it here so it is not lost. The current parameters (64 MiB, t=3) stay well above the OWASP 2024 floor of 19 MiB / t=2 in the meantime.
Suggested fix
asyncwrappers onVaultthatspawn_blocking, so no synchronous path can reach it by accident.PR to follow.