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:
@@ -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<Uint8Array> {
|
||||
return sodiumLib.crypto_pwhash(
|
||||
KEY_LEN,
|
||||
passphrase,
|
||||
async function deriveKey(passphrase: string, salt: Uint8Array, _sodiumLib: typeof sodium): Promise<Uint8Array> {
|
||||
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(
|
||||
|
||||
@@ -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.
|
||||
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<Uint8Array> {
|
||||
async function deriveKey(userId: string, salt: Uint8Array, _s: typeof sodium): Promise<Uint8Array> {
|
||||
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<VaultState> {
|
||||
|
||||
Reference in New Issue
Block a user