DEV Community

Cover image for Building the Ultimate Local-First Privacy Suite with Rust & Tauri v2
powergr
powergr

Posted on

Building the Ultimate Local-First Privacy Suite with Rust & Tauri v2

In the world of privacy tools, there is often a trade-off: Security vs. Convenience. You either get a command-line tool that is impossible to use or a slick cloud app that owns your encryption keys.

With the release of QRE Privacy Toolkit v2.5.4, we aimed to break that compromise.

We pivoted from our experimental roots (originally "Quantum Locker") to build a practical Swiss Army Knife for Digital Privacy. It runs natively on Windows, macOS, Linux, and Android, entirely offline, with zero servers.

Here is a deep dive into how we built it, the technical hurdles we overcame moving from Desktop to Mobile, and the Rust code that powers it.


1. The Core Philosophy: Local-First

Most modern apps are "Cloud-First." They verify your password on a server, store your vault in an S3 bucket, and sync via APIs.

QRE Toolkit is Local-First.

  • Storage: Your data lives in keychain.json on your device.
  • Crypto: AES-256-GCM encryption happens on your CPU.
  • Sync: None. We don't want your data.

To achieve this cross-platform performance without rewriting the app three times (Swift, Kotlin, C#), we chose Tauri v2. Tauri allows us to write the logic in Rust (for safety and speed) and the UI in React/TypeScript.


2. Challenge #1: Breaking the 4GB RAM Limit

In early versions, QRE loaded files into memory to encrypt them. This was fine for PDFs, but if a user tried to encrypt a 10GB video backup, the app crashed (OOM).

The Solution: Chunked Streaming Encryption

In v2.5, we introduced the V5 Streaming Engine. Instead of reading the whole file, we read 1MB chunks, compress them, encrypt them with a rolling nonce, and write them to disk immediately.

Here is the Rust logic for handling unlimited file sizes with constant RAM usage:

// src-tauri/src/crypto_stream.rs

pub fn encrypt_file_stream(
    input_path: &str,
    output_path: &str,
    master_key: &MasterKey,
    // ... args ...
) -> Result<()> {
    let mut input_file = BufReader::new(File::open(input_path)?);
    let mut output_file = BufWriter::new(File::create(output_path)?);

    // 1MB Chunk Buffer
    let mut buffer = vec![0u8; 1024 * 1024]; 
    let mut chunk_index: u64 = 0;

    loop {
        let bytes_read = input_file.read(&mut buffer)?;
        if bytes_read == 0 { break; } // EOF

        let chunk_data = &buffer[..bytes_read];

        // 1. Compress (Zstd)
        let compressed = compress_chunk(chunk_data, level)?;

        // 2. Derive Unique Nonce (Base + Index)
        // Prevents pattern analysis on identical chunks
        let nonce = calculate_nonce(&base_nonce, chunk_index);

        // 3. Encrypt (AES-256-GCM)
        let ciphertext = cipher.encrypt(nonce, compressed.as_ref())?;

        // 4. Write [Size][Data] to stream
        output_file.write_all(&(ciphertext.len() as u32).to_le_bytes())?;
        output_file.write_all(&ciphertext)?;

        chunk_index += 1;
    }
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

This ensures that whether you encrypt a 1MB photo or a 1TB drive image, the app never uses more than ~50MB of RAM.


3. Challenge #2: The Android Filesystem Nightmare

Porting a desktop Rust app to Android exposed a major platform difference: File Paths vs. Content URIs.

On Desktop, you open C:\Users\Docs\file.txt.
On Android, you get content://com.android.providers.media.documents/document/image%3A1234.

Standard Rust functions like std::fs::File::open() cannot read Android Content URIs.

The Solution: A Hybrid Input System

We implemented a logic fork in our Tauri command handler.

  1. Desktop: We pass the file path string. Rust opens it efficiently.
  2. Android: The React frontend reads the file into a byte array (Uint8Array) using Tauri's filesystem plugin and passes the raw bytes to Rust.
// src-tauri/src/commands.rs

#[tauri::command]
pub async fn lock_file(
    app: AppHandle,
    state: tauri::State<'_, SessionState>,
    file_paths: Vec<String>, 
    keyfile_path: Option<String>, 
    keyfile_bytes: Option<Vec<u8>> // <--- The Hybrid Solution
) -> CommandResult<Vec<BatchItemResult>> {

    // Logic:
    // If we are on Android, 'keyfile_bytes' will contain data.
    // If we are on Desktop, we use 'keyfile_path' to load it from disk.

    let keyfile_hash = if let Some(bytes) = keyfile_bytes {
         // Handle Android (Raw Data)
         sha2::Sha256::digest(&bytes).to_vec()
    } else {
         // Handle Desktop (File Path)
         utils::process_keyfile(keyfile_path)?
    };

    // ... proceed to encryption ...
}
Enter fullscreen mode Exit fullscreen mode

This allowed us to maintain a single codebase while respecting the strict sandboxing of Android 11+.


4. Privacy Tool Spotlight: The Breach Check

One of the new tools in v2.5 is the Password Breach Checker. We wanted users to check if their password was leaked (using the HaveIBeenPwned database), but we refused to send users' passwords to any server.

The Implementation: k-Anonymity

We used a technique called k-Anonymity.

  1. We hash the password locally (SHA-1).
  2. We take the first 5 characters of the hash.
  3. We send only those 5 characters to the API.
  4. The API returns ~500 hashes that start with those 5 characters.
  5. We scan the list locally to see if the full hash matches.

The Code:

// src-tauri/src/breach.rs

pub async fn check_pwned(password: &str) -> Result<BreachResult> {
    // 1. Hash Locally
    let mut hasher = Sha1::new();
    hasher.update(password.as_bytes());
    let hash = format!("{:X}", hasher.finalize());

    // 2. Split Hash (k-Anonymity)
    let prefix = &hash[0..5]; // Send this
    let suffix = &hash[5..];  // Keep this secret

    // 3. Query API
    let url = format!("https://api.pwnedpasswords.com/range/{}", prefix);
    let response = client.get(&url).send().await?.text().await?;

    // 4. Verify Locally
    for line in response.lines() {
        let parts: Vec<&str> = line.split(':').collect();
        if parts[0] == suffix {
            return Ok(BreachResult { found: true, count: parts[1].parse()? });
        }
    }

    Ok(BreachResult { found: false, count: 0 })
}
Enter fullscreen mode Exit fullscreen mode

The server never sees the password, yet the user gets an accurate result.


5. Metadata Scrubbing: Sanitizing Archives

Users often zip photos before sending them. However, standard ZIP tools preserve timestamps, file comments, and sometimes User IDs (UID/GID), which can fingerprint the creator.

In the Metadata Cleaner tool, we implemented a "Repack Strategy." We don't just modify the zip; we stream the content into a brand new container with sanitized headers.

// src-tauri/src/cleaner.rs

fn clean_zip_metadata(input: &Path, output: &Path) -> Result<()> {
    let mut zip_writer = zip::ZipWriter::new(File::create(output)?);

    // Wipe Global Comment
    zip_writer.set_comment("");

    for i in 0..archive.len() {
        let mut file = archive.by_index(i)?;

        // Sanitize File Options
        let options = SimpleFileOptions::default()
            .compression_method(file.compression())
            .unix_permissions(0o755); // Reset Permissions/UIDs

        // Copy raw data, leaving old metadata behind
        zip_writer.start_file(file.name(), options)?;
        std::io::copy(&mut file, &mut zip_writer)?;
    }
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

6. What's Next?

QRE Privacy Toolkit v2.5.4 includes 9 tools: File Encryption, Password Vault, Secure Notes, Private Bookmarks, Clipboard Manager, Metadata Cleaner, Breach Check, QR Generator, and Secure Shredder.

We are currently working on v2.6, which will explore Steganography (hiding encrypted data inside images) and TOTP Authenticator support.

Try it Yourself

The project is 100% open source. You can audit the code or download the latest release for your platform.

👉 View on GitHub
👉 Download v2.5.4

Top comments (0)