Files
ChatApp/apps/desktop/src-tauri/src/crypto.rs
T
byGalax 725a7e0364 perf(crypto): native Argon2id via dryoc — 6x faster vault unlock
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
2026-04-21 10:46:26 +02:00

288 lines
9.6 KiB
Rust

// 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<Vec<u8>, String> {
B64.decode(s).map_err(|e| format!("invalid base64: {}", e))
}
// ---------------------------------------------------------------------------
// Random
// ---------------------------------------------------------------------------
#[tauri::command]
pub fn crypto_random_bytes(len: usize) -> Result<String, String> {
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<String, String> {
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<String, String> {
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<KeyPairB64, String> {
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<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
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<String>,
}
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<String, String> {
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))
}