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
This commit is contained in:
Generated
+23
@@ -680,6 +680,8 @@ dependencies = [
|
|||||||
name = "chat-app-desktop"
|
name = "chat-app-desktop"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"dryoc",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
@@ -1242,6 +1244,27 @@ dependencies = [
|
|||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "dtoa"
|
name = "dtoa"
|
||||||
version = "1.0.11"
|
version = "1.0.11"
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ tauri-plugin-fs = "2"
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
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]
|
[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
|
||||||
tauri-plugin-global-shortcut = "2"
|
tauri-plugin-global-shortcut = "2"
|
||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2"
|
||||||
|
|||||||
@@ -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<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))
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
mod crypto;
|
||||||
|
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
use tauri::{
|
use tauri::{
|
||||||
menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem},
|
menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem},
|
||||||
@@ -48,6 +50,17 @@ fn handle_menu_event(app: &AppHandle, event: MenuEvent) {
|
|||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
let mut builder = tauri::Builder::default()
|
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_notification::init())
|
||||||
.plugin(tauri_plugin_sql::Builder::default().build())
|
.plugin(tauri_plugin_sql::Builder::default().build())
|
||||||
.plugin(tauri_plugin_fs::init())
|
.plugin(tauri_plugin_fs::init())
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { getCryptoBackend } from '@chat-app/shared/crypto';
|
import { getCryptoBackend } from '@chat-app/shared/crypto';
|
||||||
import sodium from 'libsodium-wrappers-sumo';
|
import sodium from 'libsodium-wrappers-sumo';
|
||||||
|
|
||||||
|
import { pwhashArgon2id } from './nativeCryptoOps';
|
||||||
|
|
||||||
// Encrypts/decrypts the device private key with a user-provided passphrase
|
// 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
|
// so the backup string can be safely written down or stored in a password
|
||||||
// manager. Uses Argon2id (libsodium crypto_pwhash) for the KDF and
|
// manager. Uses Argon2id (libsodium crypto_pwhash) for the KDF and
|
||||||
@@ -36,15 +38,13 @@ function unb64url(s: string): Uint8Array {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deriveKey(passphrase: string, salt: Uint8Array, sodiumLib: typeof sodium): Promise<Uint8Array> {
|
async function deriveKey(passphrase: string, salt: Uint8Array, _sodiumLib: typeof sodium): Promise<Uint8Array> {
|
||||||
return sodiumLib.crypto_pwhash(
|
return pwhashArgon2id({
|
||||||
KEY_LEN,
|
password: passphrase,
|
||||||
passphrase,
|
|
||||||
salt,
|
salt,
|
||||||
sodiumLib.crypto_pwhash_OPSLIMIT_MODERATE,
|
outLen: KEY_LEN,
|
||||||
sodiumLib.crypto_pwhash_MEMLIMIT_MODERATE,
|
preset: 'moderate',
|
||||||
sodiumLib.crypto_pwhash_ALG_ARGON2ID13,
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exportDeviceKey(
|
export async function exportDeviceKey(
|
||||||
|
|||||||
@@ -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<Uint8Array> {
|
||||||
|
if (nativeAvailable()) {
|
||||||
|
try {
|
||||||
|
const b64 = await invoke<string>('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<Uint8Array> {
|
||||||
|
if (nativeAvailable()) {
|
||||||
|
try {
|
||||||
|
const b64 = await invoke<string>('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<Uint8Array> {
|
||||||
|
if (!nativeAvailable()) throw new Error('native crypto unavailable');
|
||||||
|
const b64 = await invoke<string>('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<Uint8Array> {
|
||||||
|
if (!nativeAvailable()) throw new Error('native crypto unavailable');
|
||||||
|
const b64 = await invoke<string>('crypto_secretbox_decrypt', {
|
||||||
|
ciphertextB64: bytesToB64(ciphertext),
|
||||||
|
nonceB64: bytesToB64(nonce),
|
||||||
|
keyB64: bytesToB64(key),
|
||||||
|
});
|
||||||
|
return b64ToBytes(b64);
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import { appLocalDataDir } from '@tauri-apps/api/path';
|
|||||||
// is the compact build without Argon2 — vault KDF would error otherwise.
|
// is the compact build without Argon2 — vault KDF would error otherwise.
|
||||||
import sodium from 'libsodium-wrappers-sumo';
|
import sodium from 'libsodium-wrappers-sumo';
|
||||||
|
|
||||||
|
import { pwhashArgon2id } from './nativeCryptoOps';
|
||||||
|
|
||||||
// Encrypted single-file SecretStore for Tauri. Replaces the flaky
|
// Encrypted single-file SecretStore for Tauri. Replaces the flaky
|
||||||
// tauri-plugin-stronghold implementation.
|
// tauri-plugin-stronghold implementation.
|
||||||
//
|
//
|
||||||
@@ -76,16 +78,14 @@ function unb64url(s: string): Uint8Array {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deriveKey(userId: string, salt: Uint8Array, s: typeof sodium): Promise<Uint8Array> {
|
async function deriveKey(userId: string, salt: Uint8Array, _s: typeof sodium): Promise<Uint8Array> {
|
||||||
const passphrase = 'chatapp-vault-v1:' + userId;
|
const passphrase = 'chatapp-vault-v1:' + userId;
|
||||||
return s.crypto_pwhash(
|
return pwhashArgon2id({
|
||||||
KEY_LEN,
|
password: passphrase,
|
||||||
passphrase,
|
|
||||||
salt,
|
salt,
|
||||||
s.crypto_pwhash_OPSLIMIT_MODERATE,
|
outLen: KEY_LEN,
|
||||||
s.crypto_pwhash_MEMLIMIT_MODERATE,
|
preset: 'moderate',
|
||||||
s.crypto_pwhash_ALG_ARGON2ID13,
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadOrCreateVault(userId: string): Promise<VaultState> {
|
async function loadOrCreateVault(userId: string): Promise<VaultState> {
|
||||||
|
|||||||
Reference in New Issue
Block a user