Encrypt Tauri local storage at rest with age + OS keychain

7 min readYaseen Khatib · MERN + AI Architect
Cover illustration: Encrypt Tauri local storage at rest with age + OS keychain

Why this pattern

Local-first apps should use local-first cryptography. With Tauri you can keep secrets on the machine, skip cloud KMS, and rely on hardware/OS protections. The approach:

  • age with X25519 recipients for envelope/file encryption
  • Store the age private key only in the OS keychain using Rust’s keyring crate
  • Encrypt JSON/store/SQLite on write, decrypt on read, never write plaintext to disk

You get a zero-cloud, cross-platform setup that’s easy to rotate and audit.

How do you encrypt Tauri local storage at rest without a server?

Use age for encryption and the OS keychain for the age private key. On first run, generate an X25519 keypair, save the secret key string in the keychain, and load it later to decrypt. Write your store (JSON or a SQLite export) through a streaming encryptor to disk. The frontend never touches raw keys; it only calls into Rust commands.

OS keychain mapping

OS Backend Notes
macOS Keychain User-unlocked, biometric-gatable, audits
Windows Credential Manager (DPAPI) Secrets bound to user profile
Linux Secret Service (libsecret/gnome-keyring/kwallet) Requires a running secret service; headless may need policy decisions

Project skeleton

  • Tauri 1.x
  • Rust crates: age, keyring, anyhow
  • Frontend: call Tauri commands to read/write the encrypted store

Cargo.toml snippet:

[dependencies]
age = "0.10"
keyring = "2"
anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = { version = "1.5", features = ["api-all"] }

How can I use age with the OS keychain in a Tauri app?

Create an age X25519 identity, keep only its secret key in the keychain, and store the public recipient in app config. For reads and writes, pull the secret from the keychain, rebuild the identity, and stream-encrypt or decrypt files. This removes passphrases from the flow and keeps UX native on every platform.

Rust: key management and stream encryption

// src-tauri/src/crypto.rs
use anyhow::{anyhow, Context, Result};
use age::{Decryptor, Encryptor};
use age::x25519::{Identity, Recipient};
use keyring::Entry;
use std::io::{Read, Write};
use std::fs::{self, File};
use std::path::PathBuf;
use std::str::FromStr;
use tauri::AppHandle;

const SERVICE: &str = "com.acme.zero_cloud_app";
const ACCOUNT: &str = "age-x25519-identity"; // stable across versions
const STORE_FILENAME: &str = "store.age"; // encrypted JSON blob

fn key_entry() -> Entry {
  Entry::new(SERVICE, ACCOUNT)
}

fn app_store_path(app: &AppHandle) -> Result<PathBuf> {
  let dir = app.path_resolver()
    .app_data_dir()
    .ok_or_else(|| anyhow!("no app_data_dir"))?;
  fs::create_dir_all(&dir).ok();
  Ok(dir.join(STORE_FILENAME))
}

pub fn ensure_identity(app: &AppHandle) -> Result<String> {
  let entry = key_entry();
  match entry.get_password() {
    Ok(secret) => {
      let id = Identity::from_str(&secret)
        .context("keychain identity parse failed")?;
      Ok(id.to_public().to_string())
    }
    Err(_) => {
      // First run: generate and store
      let id = Identity::generate();
      let secret = id.to_string(); // e.g., AGE-SECRET-KEY-1...
      entry.set_password(&secret)
        .context("failed to persist age identity in keychain")?;
      Ok(id.to_public().to_string())
    }
  }
}

pub fn encrypt_store(app: &AppHandle, recipient_str: &str, plaintext: &[u8]) -> Result<usize> {
  let recipient = Recipient::from_str(recipient_str)
    .context("invalid recipient")?;
  let out_path = app_store_path(app)?;
  let out_file = File::create(&out_path).context("open output")?;

  let encryptor = Encryptor::with_recipients(vec![Box::new(recipient)]);
  let mut writer = encryptor
    .wrap_output(out_file)
    .context("wrap_output")?;

  let n = writer.write(plaintext).context("encrypt write")?;
  writer.finish().context("finish")?;
  Ok(n)
}

pub fn decrypt_store(app: &AppHandle) -> Result<Option<Vec<u8>>> {
  let path = app_store_path(app)?;
  if !path.exists() { return Ok(None); }

  let mut file = File::open(&path).context("open encrypted store")?;
  let decryptor = Decryptor::new(&mut file).context("parse age header")?;

  match decryptor {
    Decryptor::Recipients(d) => {
      let secret = key_entry().get_password().context("keychain get")?;
      let id = Identity::from_str(&secret).context("identity parse")?;
      let mut reader = d.decrypt(std::iter::once(&id as &dyn age::Identity))
        .context("no matching identity")?;
      let mut buf = Vec::new();
      reader.read_to_end(&mut buf).context("decrypt read")?;
      Ok(Some(buf))
    }
    Decryptor::Passphrase(_) => Err(anyhow!("unexpected passphrase mode")),
  }
}

pub fn rotate_identity(app: &AppHandle) -> Result<String> {
  // Decrypt with old key
  let plaintext = match decrypt_store(app)? { Some(p) => p, None => Vec::new() };

  // Generate new identity, store in keychain
  let new_id = Identity::generate();
  key_entry().set_password(&new_id.to_string())
    .context("persist new identity")?;

  // Re-encrypt with new recipient
  let new_recipient = new_id.to_public().to_string();
  encrypt_store(app, &new_recipient, &plaintext).context("reencrypt with new key")?;
  Ok(new_recipient)
}

Expose these via Tauri commands:

// src-tauri/src/main.rs
mod crypto;
use tauri::State;

#[tauri::command]
fn init_identity(app: tauri::AppHandle) -> Result<String, String> {
  crypto::ensure_identity(&app).map_err(|e| e.to_string())
}

#[tauri::command]
fn write_store(app: tauri::AppHandle, json: String) -> Result<usize, String> {
  let recipient = crypto::ensure_identity(&app).map_err(|e| e.to_string())?;
  crypto::encrypt_store(&app, &recipient, json.as_bytes()).map_err(|e| e.to_string())
}

#[tauri::command]
fn read_store(app: tauri::AppHandle) -> Result<Option<String>, String> {
  match crypto::decrypt_store(&app) {
    Ok(Some(buf)) => Ok(Some(String::from_utf8_lossy(&buf).to_string())),
    Ok(None) => Ok(None),
    Err(e) => Err(e.to_string()),
  }
}

#[tauri::command]
fn rotate_key(app: tauri::AppHandle) -> Result<String, String> {
  crypto::rotate_identity(&app).map_err(|e| e.to_string())
}

fn main() {
  tauri::Builder::default()
    .invoke_handler(tauri::generate_handler![init_identity, write_store, read_store, rotate_key])
    .run(tauri::generate_context!())
    .expect("error while running tauri application");
}

Frontend: thin invoke wrappers

// src/lib/secureStore.ts
import { invoke } from '@tauri-apps/api/tauri'

export async function initIdentity(): Promise<string> {
  return await invoke<string>('init_identity')
}

export async function readSecureStore<T = any>(): Promise<T | null> {
  const json = await invoke<string | null>('read_store')
  return json ? JSON.parse(json) as T : null
}

export async function writeSecureStore(obj: unknown): Promise<number> {
  const json = JSON.stringify(obj)
  return await invoke<number>('write_store', { json })
}

export async function rotateKey(): Promise<string> {
  return await invoke<string>('rotate_key')
}

Usage at startup:

// Example boot
await initIdentity()
const cached = await readSecureStore<Record<string, unknown>>()
const state = cached ?? { onboardingDone: false }
// ... app runs ...
await writeSecureStore(state)

What about SQLite or large files?

  • SQLite: For simple local-first apps, write JSON snapshots encrypted with age on graceful shutdown and restore on startup. For write-heavy cases, consider SQLCipher or LiteStream-like strategies. You can still encrypt periodic full or differential backups with age, keeping the live DB plaintext only in OS-protected user space.
  • Large files: Use the same streaming API; stream from a File or BufReader into the encryptor. Age’s streaming is chunked and uses constant memory.
pub fn encrypt_file(app: &AppHandle, recipient_str: &str, src: &std::path::Path, dst: &std::path::Path) -> anyhow::Result<()> {
  let recipient = Recipient::from_str(recipient_str)?;
  let in_file = File::open(src)?;
  let out_file = File::create(dst)?;
  let encryptor = Encryptor::with_recipients(vec![Box::new(recipient)]);
  let mut writer = encryptor.wrap_output(out_file)?;
  std::io::copy(&mut std::io::BufReader::new(in_file), &mut writer)?;
  writer.finish()?;
  Ok(())
}

Operational pitfalls and hardening

  • Stable service/account names: Keep SERVICE and ACCOUNT the same across releases or you’ll orphan keys in the keychain.
  • Linux headless: If Secret Service is missing, keyring fails. Decide whether to block startup, guide the user, or provide an explicit “unsafe dev mode” that falls back to a passphrase.
  • Backups: Keychain-bound secrets usually follow system/user backups. If you move encrypted files to another machine, you must export/import the key or encrypt to multiple recipients. Age supports multiple recipients; add a “recovery” recipient stored as a printed paper key or a YubiHSM-managed key on admin devices.
  • Multiple recipients: Encrypt to [current app recipient, recovery recipient] to reduce device-loss risk. With age, pass both recipients to Encryptor::with_recipients.
  • Memory hygiene: Hold decrypted data in memory only as long as needed. Don’t log it. Consider zeroize for sensitive buffers if the material is high value.
  • File integrity: Age authenticates ciphertext contents but not filenames or metadata. If you store metadata in plaintext sidecars, add a per-file MAC if needed.

How do I rotate encryption keys in a Tauri desktop app?

Decrypt with the old private key from the keychain, generate a new identity, update the keychain entry, then re-encrypt with the new recipient. Provide a one-shot command that is idempotent and transactional, and record rotation success. If you need staged rotation, support multiple recipients during the cutover.

The rotate_key command above implements a simple one-step rotation. For many files, rewrap in batches; include both old and new recipients while migrating, then remove the old one.

Threat model and trade-offs

  • Protects at rest against disk theft and offline exfiltration. An attacker needs the OS account or a break in the OS keychain/DPAPI/Keychain.
  • If the user session is unlocked, malware with user privileges can call the same Tauri commands. Add app-level authorization or platform prompts if on-box malware is in scope.
  • No passphrase UX: Good for productivity apps. For higher assurance, offer an optional user passphrase by adding a passphrase recipient (age passphrase) alongside the device key.

Testing and DX tips

  • Dev vs prod: Use different SERVICE values for dev builds to avoid clobbering real keys. Gate with build-time cfg or environment.
  • CI: Avoid running crypto tests that require a keychain in headless CI by default. Feature-gate or mock the keyring layer.
  • Telemetry: Track rotation successes and failures locally. Do not export key material or recipient strings unless absolutely required.

Conclusion

Use age for modern crypto, the OS keychain for custody, and streaming I/O for performance. It ships cleanly, keeps users out of key management, and works across macOS, Windows, and Linux. For most local-first desktop apps, this is the default that holds up.

Need an engineer who can build this?

I'm Yaseen Khatib — a Senior Full-Stack AI Engineer (MERN + TypeScript) who ships production AI systems solo. Open to senior and lead roles, remote or on-site.