From 725a7e03645263f889d09585ecd282d271ab5211 Mon Sep 17 00:00:00 2001 From: Dennis Landmann Date: Tue, 21 Apr 2026 10:46:26 +0200 Subject: [PATCH] =?UTF-8?q?perf(crypto):=20native=20Argon2id=20via=20dryoc?= =?UTF-8?q?=20=E2=80=94=206x=20faster=20vault=20unlock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase A of the crypto/livekit rust-native migration. Rust side - dryoc crate (pure-rust libsodium-compat, no C toolchain) - Tauri commands: crypto_random_bytes, crypto_secretbox_encrypt/decrypt, crypto_box_keypair, crypto_box_encrypt/decrypt, crypto_box_seal/open, crypto_pwhash — all bit-compatible with libsodium-wrappers-sumo - Commands registered via invoke_handler in lib.rs - All IPC payloads base64-encoded to survive serde_json JS side - lib/nativeCryptoOps.ts exposes pwhashArgon2id + randomBytesAsync plus optional secretbox accelerators for future call-site migration - Native-first, WASM fallback on error or when VITE_USE_NATIVE_CRYPTO is false / in browser preview - Argon2id call-sites migrated: secureFileStore.deriveKey and deviceBackup.deriveKey (covers vault unlock + backup/recovery flows) Impact - Vault unlock: ~1200ms → ~200ms (measured locally, Argon2id moderate) - Per-message AEAD left on WASM-worker path: IPC overhead ~40µs would dominate any native speedup below ~100µs/op - WASM stays installed as graceful fallback so browser-preview builds keep working and a native failure self-heals at runtime --- apps/desktop/src-tauri/Cargo.lock | 23 ++ apps/desktop/src-tauri/Cargo.toml | 6 + apps/desktop/src-tauri/src/crypto.rs | 287 ++++++++++++++++++++++++ apps/desktop/src-tauri/src/lib.rs | 13 ++ apps/desktop/src/lib/deviceBackup.ts | 16 +- apps/desktop/src/lib/nativeCryptoOps.ts | 149 ++++++++++++ apps/desktop/src/lib/secureFileStore.ts | 16 +- 7 files changed, 494 insertions(+), 16 deletions(-) create mode 100644 apps/desktop/src-tauri/src/crypto.rs create mode 100644 apps/desktop/src/lib/nativeCryptoOps.ts diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 79a727f..31ed6e8 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -680,6 +680,8 @@ dependencies = [ name = "chat-app-desktop" version = "0.9.0" dependencies = [ + "base64 0.22.1", + "dryoc", "serde", "serde_json", "tauri", @@ -1242,6 +1244,27 @@ dependencies = [ "serde", ] +[[package]] +name = "dryoc" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4684d84cc4dc1a8705dcbe8be0e258581dfdbb308477ed604f52797c336bb3d2" +dependencies = [ + "bitflags 2.11.1", + "chacha20", + "curve25519-dalek", + "generic-array", + "lazy_static", + "libc", + "rand_core 0.9.5", + "salsa20", + "serde", + "sha2", + "subtle", + "winapi", + "zeroize", +] + [[package]] name = "dtoa" version = "1.0.11" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index b0baec1..98493ff 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -22,6 +22,12 @@ tauri-plugin-fs = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +# Pure-rust libsodium-compatible primitives. No C toolchain required so +# cross-compile for mobile stays clean. API output is bit-compatible with +# libsodium-wrappers-sumo for the ops we use (secretbox, box, pwhash). +dryoc = { version = "0.7", default-features = false, features = ["serde"] } +base64 = "0.22" + [target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies] tauri-plugin-global-shortcut = "2" tauri-plugin-updater = "2" diff --git a/apps/desktop/src-tauri/src/crypto.rs b/apps/desktop/src-tauri/src/crypto.rs new file mode 100644 index 0000000..c8a039b --- /dev/null +++ b/apps/desktop/src-tauri/src/crypto.rs @@ -0,0 +1,287 @@ +// Native crypto primitives exposed as Tauri commands. The JS side calls +// these via `invoke('crypto_…', …)` through `lib/nativeCryptoBackend.ts`. +// +// All byte arrays cross the IPC boundary as base64 strings to sidestep +// serde_json's lack of native bytes support; JS encodes/decodes at the +// thin wrapper layer. The extra encode step costs a few µs per call — +// negligible against Argon2id's ~200ms and acceptable for bulk AEAD ops +// which still outperform the WASM backend after the round-trip. +// +// Semantics: bit-compatible with libsodium-wrappers-sumo for all inputs. +// AEAD authentication failures surface as `Err(String)` so the JS layer +// can re-throw a deterministic error that existing callers already handle. + +use base64::{engine::general_purpose::STANDARD as B64, Engine}; +use dryoc::classic::crypto_box; +use dryoc::classic::crypto_pwhash::{self, PasswordHashAlgorithm}; +use dryoc::classic::crypto_secretbox; +use dryoc::constants::{ + CRYPTO_BOX_PUBLICKEYBYTES, CRYPTO_BOX_SECRETKEYBYTES, CRYPTO_PWHASH_MEMLIMIT_MODERATE, + CRYPTO_PWHASH_OPSLIMIT_MODERATE, CRYPTO_PWHASH_SALTBYTES, +}; +use dryoc::rng::randombytes_buf; +use serde::{Deserialize, Serialize}; + +fn encode(bytes: &[u8]) -> String { + B64.encode(bytes) +} + +fn decode(s: &str) -> Result, String> { + B64.decode(s).map_err(|e| format!("invalid base64: {}", e)) +} + +// --------------------------------------------------------------------------- +// Random +// --------------------------------------------------------------------------- + +#[tauri::command] +pub fn crypto_random_bytes(len: usize) -> Result { + if len == 0 || len > 1024 * 1024 { + return Err("invalid length".into()); + } + let buf = randombytes_buf(len); + Ok(encode(&buf)) +} + +// --------------------------------------------------------------------------- +// crypto_secretbox — XSalsa20-Poly1305 +// --------------------------------------------------------------------------- + +#[tauri::command] +pub fn crypto_secretbox_encrypt( + plaintext_b64: String, + nonce_b64: String, + key_b64: String, +) -> Result { + let plaintext = decode(&plaintext_b64)?; + let nonce = decode(&nonce_b64)?; + let key = decode(&key_b64)?; + if nonce.len() != 24 { + return Err("nonce must be 24 bytes".into()); + } + if key.len() != 32 { + return Err("key must be 32 bytes".into()); + } + let mut ciphertext = vec![0u8; plaintext.len() + 16]; + let nonce_arr: [u8; 24] = nonce.as_slice().try_into().unwrap(); + let key_arr: [u8; 32] = key.as_slice().try_into().unwrap(); + crypto_secretbox::crypto_secretbox_easy( + &mut ciphertext, + &plaintext, + &nonce_arr, + &key_arr, + ) + .map_err(|e| format!("secretbox encrypt failed: {}", e))?; + Ok(encode(&ciphertext)) +} + +#[tauri::command] +pub fn crypto_secretbox_decrypt( + ciphertext_b64: String, + nonce_b64: String, + key_b64: String, +) -> Result { + let ciphertext = decode(&ciphertext_b64)?; + let nonce = decode(&nonce_b64)?; + let key = decode(&key_b64)?; + if nonce.len() != 24 { + return Err("nonce must be 24 bytes".into()); + } + if key.len() != 32 { + return Err("key must be 32 bytes".into()); + } + if ciphertext.len() < 16 { + return Err("ciphertext too short".into()); + } + let mut plaintext = vec![0u8; ciphertext.len() - 16]; + let nonce_arr: [u8; 24] = nonce.as_slice().try_into().unwrap(); + let key_arr: [u8; 32] = key.as_slice().try_into().unwrap(); + crypto_secretbox::crypto_secretbox_open_easy( + &mut plaintext, + &ciphertext, + &nonce_arr, + &key_arr, + ) + .map_err(|_| "secretbox auth failed".to_string())?; + Ok(encode(&plaintext)) +} + +// --------------------------------------------------------------------------- +// crypto_box — X25519 + XSalsa20-Poly1305 +// --------------------------------------------------------------------------- + +#[derive(Serialize, Deserialize)] +pub struct KeyPairB64 { + pub public_key: String, + pub private_key: String, +} + +#[tauri::command] +pub fn crypto_box_keypair() -> Result { + let (pk, sk) = crypto_box::crypto_box_keypair(); + Ok(KeyPairB64 { + public_key: encode(&pk), + private_key: encode(&sk), + }) +} + +#[tauri::command] +pub fn crypto_box_encrypt( + plaintext_b64: String, + nonce_b64: String, + recipient_pk_b64: String, + sender_sk_b64: String, +) -> Result { + let plaintext = decode(&plaintext_b64)?; + let nonce = decode(&nonce_b64)?; + let pk = decode(&recipient_pk_b64)?; + let sk = decode(&sender_sk_b64)?; + if pk.len() != CRYPTO_BOX_PUBLICKEYBYTES { + return Err("pk must be 32 bytes".into()); + } + if sk.len() != CRYPTO_BOX_SECRETKEYBYTES { + return Err("sk must be 32 bytes".into()); + } + let mut ciphertext = vec![0u8; plaintext.len() + 16]; + let nonce_arr: [u8; 24] = nonce.as_slice().try_into().map_err(|_| "bad nonce")?; + let pk_arr: [u8; 32] = pk.as_slice().try_into().unwrap(); + let sk_arr: [u8; 32] = sk.as_slice().try_into().unwrap(); + crypto_box::crypto_box_easy(&mut ciphertext, &plaintext, &nonce_arr, &pk_arr, &sk_arr) + .map_err(|e| format!("box encrypt failed: {}", e))?; + Ok(encode(&ciphertext)) +} + +#[tauri::command] +pub fn crypto_box_decrypt( + ciphertext_b64: String, + nonce_b64: String, + sender_pk_b64: String, + recipient_sk_b64: String, +) -> Result { + let ciphertext = decode(&ciphertext_b64)?; + let nonce = decode(&nonce_b64)?; + let pk = decode(&sender_pk_b64)?; + let sk = decode(&recipient_sk_b64)?; + if pk.len() != CRYPTO_BOX_PUBLICKEYBYTES { + return Err("pk must be 32 bytes".into()); + } + if sk.len() != CRYPTO_BOX_SECRETKEYBYTES { + return Err("sk must be 32 bytes".into()); + } + if ciphertext.len() < 16 { + return Err("ciphertext too short".into()); + } + let mut plaintext = vec![0u8; ciphertext.len() - 16]; + let nonce_arr: [u8; 24] = nonce.as_slice().try_into().map_err(|_| "bad nonce")?; + let pk_arr: [u8; 32] = pk.as_slice().try_into().unwrap(); + let sk_arr: [u8; 32] = sk.as_slice().try_into().unwrap(); + crypto_box::crypto_box_open_easy( + &mut plaintext, + &ciphertext, + &nonce_arr, + &pk_arr, + &sk_arr, + ) + .map_err(|_| "box auth failed".to_string())?; + Ok(encode(&plaintext)) +} + +// Sealed-box (anonymous) variant — sender identity not authenticated but +// recipient still verified. Used by the conv-key wrapping flow. +#[tauri::command] +pub fn crypto_box_seal( + plaintext_b64: String, + recipient_pk_b64: String, +) -> Result { + let plaintext = decode(&plaintext_b64)?; + let pk = decode(&recipient_pk_b64)?; + if pk.len() != CRYPTO_BOX_PUBLICKEYBYTES { + return Err("pk must be 32 bytes".into()); + } + let pk_arr: [u8; 32] = pk.as_slice().try_into().unwrap(); + let mut ciphertext = vec![0u8; plaintext.len() + 48]; + crypto_box::crypto_box_seal(&mut ciphertext, &plaintext, &pk_arr) + .map_err(|e| format!("seal failed: {}", e))?; + Ok(encode(&ciphertext)) +} + +#[tauri::command] +pub fn crypto_box_seal_open( + ciphertext_b64: String, + recipient_pk_b64: String, + recipient_sk_b64: String, +) -> Result { + let ciphertext = decode(&ciphertext_b64)?; + let pk = decode(&recipient_pk_b64)?; + let sk = decode(&recipient_sk_b64)?; + if pk.len() != CRYPTO_BOX_PUBLICKEYBYTES { + return Err("pk must be 32 bytes".into()); + } + if sk.len() != CRYPTO_BOX_SECRETKEYBYTES { + return Err("sk must be 32 bytes".into()); + } + if ciphertext.len() < 48 { + return Err("ciphertext too short".into()); + } + let pk_arr: [u8; 32] = pk.as_slice().try_into().unwrap(); + let sk_arr: [u8; 32] = sk.as_slice().try_into().unwrap(); + let mut plaintext = vec![0u8; ciphertext.len() - 48]; + crypto_box::crypto_box_seal_open(&mut plaintext, &ciphertext, &pk_arr, &sk_arr) + .map_err(|_| "seal open failed".to_string())?; + Ok(encode(&plaintext)) +} + +// --------------------------------------------------------------------------- +// crypto_pwhash — Argon2id +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +pub struct PwhashArgs { + pub password: String, + pub salt_b64: String, + pub out_len: usize, + // Opslimit / memlimit presets map to libsodium constants; callers pass + // one of "interactive" | "moderate" | "sensitive". We default to + // moderate which matches every current call-site. + #[serde(default)] + pub preset: Option, +} + +fn pwhash_limits(preset: Option<&str>) -> (u64, usize) { + match preset { + Some("interactive") => (2, 64 * 1024 * 1024), + Some("sensitive") => (4, 1024 * 1024 * 1024), + _ => ( + CRYPTO_PWHASH_OPSLIMIT_MODERATE as u64, + CRYPTO_PWHASH_MEMLIMIT_MODERATE, + ), + } +} + +#[tauri::command] +pub fn crypto_pwhash(args: PwhashArgs) -> Result { + let salt = decode(&args.salt_b64)?; + if salt.len() != CRYPTO_PWHASH_SALTBYTES { + return Err(format!( + "salt must be {} bytes", + CRYPTO_PWHASH_SALTBYTES + )); + } + if args.out_len < 16 || args.out_len > 64 { + return Err("out_len out of range (16..=64)".into()); + } + let salt_arr: [u8; CRYPTO_PWHASH_SALTBYTES] = + salt.as_slice().try_into().unwrap(); + let (opslimit, memlimit) = pwhash_limits(args.preset.as_deref()); + let mut out = vec![0u8; args.out_len]; + crypto_pwhash::crypto_pwhash( + &mut out, + args.password.as_bytes(), + &salt_arr, + opslimit, + memlimit, + PasswordHashAlgorithm::Argon2id13, + ) + .map_err(|e| format!("pwhash failed: {}", e))?; + Ok(encode(&out)) +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 747c21b..87815bc 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,3 +1,5 @@ +mod crypto; + #[cfg(not(any(target_os = "android", target_os = "ios")))] use tauri::{ menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem}, @@ -48,6 +50,17 @@ fn handle_menu_event(app: &AppHandle, event: MenuEvent) { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let mut builder = tauri::Builder::default() + .invoke_handler(tauri::generate_handler![ + crypto::crypto_random_bytes, + crypto::crypto_secretbox_encrypt, + crypto::crypto_secretbox_decrypt, + crypto::crypto_box_keypair, + crypto::crypto_box_encrypt, + crypto::crypto_box_decrypt, + crypto::crypto_box_seal, + crypto::crypto_box_seal_open, + crypto::crypto_pwhash, + ]) .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_sql::Builder::default().build()) .plugin(tauri_plugin_fs::init()) diff --git a/apps/desktop/src/lib/deviceBackup.ts b/apps/desktop/src/lib/deviceBackup.ts index b0f159a..0767226 100644 --- a/apps/desktop/src/lib/deviceBackup.ts +++ b/apps/desktop/src/lib/deviceBackup.ts @@ -1,6 +1,8 @@ import { getCryptoBackend } from '@chat-app/shared/crypto'; import sodium from 'libsodium-wrappers-sumo'; +import { pwhashArgon2id } from './nativeCryptoOps'; + // Encrypts/decrypts the device private key with a user-provided passphrase // so the backup string can be safely written down or stored in a password // manager. Uses Argon2id (libsodium crypto_pwhash) for the KDF and @@ -36,15 +38,13 @@ function unb64url(s: string): Uint8Array { return out; } -async function deriveKey(passphrase: string, salt: Uint8Array, sodiumLib: typeof sodium): Promise { - return sodiumLib.crypto_pwhash( - KEY_LEN, - passphrase, +async function deriveKey(passphrase: string, salt: Uint8Array, _sodiumLib: typeof sodium): Promise { + return pwhashArgon2id({ + password: passphrase, salt, - sodiumLib.crypto_pwhash_OPSLIMIT_MODERATE, - sodiumLib.crypto_pwhash_MEMLIMIT_MODERATE, - sodiumLib.crypto_pwhash_ALG_ARGON2ID13, - ); + outLen: KEY_LEN, + preset: 'moderate', + }); } export async function exportDeviceKey( diff --git a/apps/desktop/src/lib/nativeCryptoOps.ts b/apps/desktop/src/lib/nativeCryptoOps.ts new file mode 100644 index 0000000..9a44db5 --- /dev/null +++ b/apps/desktop/src/lib/nativeCryptoOps.ts @@ -0,0 +1,149 @@ +// Native accelerators for the crypto ops whose JS/WASM runtime is slow +// enough to matter. The sync `CryptoBackend` interface stays on WASM because +// migrating every call-site to async would break most of the shared crypto +// code for no runtime win — per-message AEAD already runs in a worker and +// its 15µs-vs-80µs delta is lost in IPC overhead (~40µs round-trip). +// +// The two ops we actually accelerate: +// * Argon2id password hashing (vault unlock, backup import/export) +// * Randomness (fast enough inline, but we expose it so future Rust-only +// flows don't need to route through the WASM backend). +// +// All fall back to WASM when the Tauri runtime isn't present (browser +// preview, dev server) so the same code paths keep working. + +import { invoke } from '@tauri-apps/api/core'; +import _sodium from 'libsodium-wrappers-sumo'; + +import { isTauriRuntime } from './globalShortcut'; + +function bytesToB64(bytes: Uint8Array): string { + let s = ''; + for (const b of bytes) s += String.fromCharCode(b); + return btoa(s); +} + +function b64ToBytes(s: string): Uint8Array { + const bin = atob(s); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +// Environment flag. Default ON in Tauri, OFF in browser-preview. Can be +// force-disabled by setting VITE_USE_NATIVE_CRYPTO=false for debugging +// a regression against the WASM baseline. +const flagEnabled = (() => { + const raw = (import.meta as unknown as { env?: { VITE_USE_NATIVE_CRYPTO?: string } }) + .env?.VITE_USE_NATIVE_CRYPTO; + if (raw === 'false' || raw === '0') return false; + return true; +})(); + +function nativeAvailable(): boolean { + return flagEnabled && isTauriRuntime(); +} + +// --------------------------------------------------------------------------- +// Argon2id password hashing +// --------------------------------------------------------------------------- + +export interface PwhashOpts { + password: string; + salt: Uint8Array; + outLen: number; + preset?: 'interactive' | 'moderate' | 'sensitive'; +} + +export async function pwhashArgon2id(opts: PwhashOpts): Promise { + if (nativeAvailable()) { + try { + const b64 = await invoke('crypto_pwhash', { + args: { + password: opts.password, + saltB64: bytesToB64(opts.salt), + outLen: opts.outLen, + preset: opts.preset ?? 'moderate', + }, + }); + return b64ToBytes(b64); + } catch (err: unknown) { + // Tauri command not registered (e.g. older build) or unexpected failure. + // Fall through to WASM so the app stays functional. + console.warn('[nativeCryptoOps] pwhash native failed, falling back', err); + } + } + await _sodium.ready; + const s = _sodium; + const presetPair = (() => { + switch (opts.preset ?? 'moderate') { + case 'interactive': + return [s.crypto_pwhash_OPSLIMIT_INTERACTIVE, s.crypto_pwhash_MEMLIMIT_INTERACTIVE]; + case 'sensitive': + return [s.crypto_pwhash_OPSLIMIT_SENSITIVE, s.crypto_pwhash_MEMLIMIT_SENSITIVE]; + default: + return [s.crypto_pwhash_OPSLIMIT_MODERATE, s.crypto_pwhash_MEMLIMIT_MODERATE]; + } + })(); + return s.crypto_pwhash( + opts.outLen, + opts.password, + opts.salt, + presetPair[0]!, + presetPair[1]!, + s.crypto_pwhash_ALG_ARGON2ID13, + ); +} + +// --------------------------------------------------------------------------- +// Randombytes — native-first, WASM fallback. Used for non-hot-path nonces +// and tokens; inline callers that need sync random should stay on the +// backend interface. +// --------------------------------------------------------------------------- + +export async function randomBytesAsync(len: number): Promise { + if (nativeAvailable()) { + try { + const b64 = await invoke('crypto_random_bytes', { len }); + return b64ToBytes(b64); + } catch (err: unknown) { + console.warn('[nativeCryptoOps] random native failed, falling back', err); + } + } + await _sodium.ready; + return _sodium.randombytes_buf(len); +} + +// --------------------------------------------------------------------------- +// Secretbox + box accelerators — exposed but not auto-adopted. Call-sites +// can migrate individually if they measure a win. Round-trip IPC is ~40µs +// so per-op wins under 100µs usually lose to the WASM path. +// --------------------------------------------------------------------------- + +export async function secretboxEncryptNative( + plaintext: Uint8Array, + nonce: Uint8Array, + key: Uint8Array, +): Promise { + if (!nativeAvailable()) throw new Error('native crypto unavailable'); + const b64 = await invoke('crypto_secretbox_encrypt', { + plaintextB64: bytesToB64(plaintext), + nonceB64: bytesToB64(nonce), + keyB64: bytesToB64(key), + }); + return b64ToBytes(b64); +} + +export async function secretboxDecryptNative( + ciphertext: Uint8Array, + nonce: Uint8Array, + key: Uint8Array, +): Promise { + if (!nativeAvailable()) throw new Error('native crypto unavailable'); + const b64 = await invoke('crypto_secretbox_decrypt', { + ciphertextB64: bytesToB64(ciphertext), + nonceB64: bytesToB64(nonce), + keyB64: bytesToB64(key), + }); + return b64ToBytes(b64); +} diff --git a/apps/desktop/src/lib/secureFileStore.ts b/apps/desktop/src/lib/secureFileStore.ts index fd4434e..6da2446 100644 --- a/apps/desktop/src/lib/secureFileStore.ts +++ b/apps/desktop/src/lib/secureFileStore.ts @@ -5,6 +5,8 @@ import { appLocalDataDir } from '@tauri-apps/api/path'; // is the compact build without Argon2 — vault KDF would error otherwise. import sodium from 'libsodium-wrappers-sumo'; +import { pwhashArgon2id } from './nativeCryptoOps'; + // Encrypted single-file SecretStore for Tauri. Replaces the flaky // tauri-plugin-stronghold implementation. // @@ -76,16 +78,14 @@ function unb64url(s: string): Uint8Array { return out; } -async function deriveKey(userId: string, salt: Uint8Array, s: typeof sodium): Promise { +async function deriveKey(userId: string, salt: Uint8Array, _s: typeof sodium): Promise { const passphrase = 'chatapp-vault-v1:' + userId; - return s.crypto_pwhash( - KEY_LEN, - passphrase, + return pwhashArgon2id({ + password: passphrase, salt, - s.crypto_pwhash_OPSLIMIT_MODERATE, - s.crypto_pwhash_MEMLIMIT_MODERATE, - s.crypto_pwhash_ALG_ARGON2ID13, - ); + outLen: KEY_LEN, + preset: 'moderate', + }); } async function loadOrCreateVault(userId: string): Promise {