Hey DEV community! ๐
A while back, I wrote about building Repo-rterโa local-first desktop client designed to bypass GitHub's 14-day traffic log limit by saving insights directly to the user's native filesystem.
In our v0.4.1 release, we successfully migrated our primary storage layer from the WebView's volatile localStorage to a native file-system cache. But as the user base grew, we faced three new engineering challenges:
- Multi-device Sync: How do we sync local-first files across multiple machines without hosting a centralized database that compromises user privacy?
-
Plaintext Secrets: Storing highly sensitive credentialsโlike GitHub Personal Access Tokens (PATs) and cloud sync passwordsโin browser
localStorageis a massive security hazard. - Tauri v2 Stability: Graduating the codebase from release candidates to production-stable Tauri v2.
Here is a technical deep dive into how we solved these in our latest v0.4.2 - v0.4.4 releases using WebDAV, AES-256-GCM End-to-End Encryption (E2EE), and OS Native Keychains.
๐ 1. Zero-Knowledge Multi-Device Sync: WebDAV + AES-GCM-256 E2EE
To keep Repo-rter strictly local-first and self-sovereign, we chose WebDAV as our cloud storage provider. Users can sync their data using self-hosted Nextcloud instances, Synology NAS, or any standard WebDAV cloud provider.
However, sync payloads contain entire historical repository logs. Pushing these in plaintext over the wire was unacceptable.
The E2EE Encryption Pipeline
Before triggering a WebDAV PUT request, the frontend encrypts the local cache bundle using the Web Crypto API.
We derive a 256-bit AES key from a user-provided passphrase using PBKDF2 with 100,000 iterations and SHA-256. The actual encryption uses AES-256-GCM to ensure both confidentiality and authenticity (integrity checking).
Here is a simplified look at the encryption pipeline:
// src/lib/crypto.ts
export async function encryptData(plaintext: string, passphrase?: string): Promise<string> {
if (!passphrase) return plaintext; // Plaintext backup (fallback/optional)
const salt = window.crypto.getRandomValues(new Uint8Array(16));
const iv = window.crypto.getRandomValues(new Uint8Array(12));
// Derive key via PBKDF2
const baseKey = await window.crypto.subtle.importKey(
'raw',
new TextEncoder().encode(passphrase),
'PBKDF2',
false,
['deriveKey']
);
const aesKey = await window.crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt,
iterations: 100000,
hash: 'SHA-256',
},
baseKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt']
);
// Encrypt
const ciphertext = await window.crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
aesKey,
new TextEncoder().encode(plaintext)
);
// Pack salt + iv + ciphertext into a single portable string
return packPayload(salt, iv, new Uint8Array(ciphertext));
}
Minimizing Network Overhead in Rust
Reading and parsing dozens of individual JSON files on the frontend to create a backup payload would block the UI thread. Instead, we wrote a native Rust command to bundle the cache on the filesystem level:
// src-tauri/src/lib.rs
#[tauri::command]
pub fn bundle_traffic_cache(app_handle: tauri::AppHandle) -> Result<String, String> {
let path = app_handle.path().app_data_dir().map_err(|e| e.to_string())?;
let mut bundle = serde_json::Map::new();
for entry in fs::read_dir(path).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let filename = entry.file_name().to_string_lossy().to_string();
if filename.starts_with("traffic_") && filename.ends_with(".json") {
let content = fs::read_to_string(entry.path()).map_err(|e| e.to_string())?;
let json_value: serde_json::Value = serde_json::from_str(&content).map_err(|e| e.to_string())?;
bundle.insert(filename, json_value);
}
}
serde_json::to_string(&bundle).map_err(|e| e.to_string())
}
This native command bundles all repository logs into a single compact JSON payload, which the frontend encrypts and uploads to WebDAV in one request.
๐ 2. Hardware-Grade Security: OS Native Keychain Migration
While our traffic logs were encrypted, the keys to the castle (GitHub PAT, WebDAV password, and the E2EE passphrase) were still stored as plaintext strings in the WebView's localStorage. This left credentials exposed to potential XSS attacks or physical inspection of WebView files.
In v0.4.4, we migrated all credentials to the operating system's native secure store:
- macOS: Keychain Services
- Windows: Credential Manager
- Linux: Secret Service API (via D-Bus)
Direct Keychain Integration via Rust
Instead of using community plugins that lack npm packages or official audits, we directly wrapped the core Rust keyring crate.
We implemented three thin Tauri commands in Rust:
// src-tauri/src/secrets.rs
use keyring::v1::{Entry, Error};
const SERVICE: &str = "com.reporter.app";
#[tauri::command]
pub fn set_secret(key: String, value: String) -> Result<(), String> {
Entry::new(SERVICE, &key)
.map_err(|e| e.to_string())?
.set_password(&value)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_secret(key: String) -> Result<Option<String>, String> {
let entry = Entry::new(SERVICE, &key).map_err(|e| e.to_string())?;
match entry.get_password() {
Ok(value) => Ok(Some(value)),
Err(Error::NoEntry) => Ok(None),
Err(e) => Err(e.to_string()),
}
}
Since the default features of keyring 4.x use the zbus crate for D-Bus communication on Linux, it compiles down to pure Rust without linking to any C dependencies (like libsecret or openssl). This kept our CI/CD pipeline clean without requiring apt-get modifications!
Avoiding Boot-Time Race Conditions
When the application starts, it immediately fires off background sync checks. If the keychain migration runs asynchronously in the background, there is a race condition where the app tries to read a token before the migration script has finished writing it.
To solve this, we designed a fail-safe reading fallback and a strict transaction-like migration:
-
Read Path Fallback:
When reading a secret, if it's missing from the Keychain, the gateway checks
localStorageas a fallback. This guarantees the app functions normally even if migration hasn't run or completed yet. - Verify-Before-Delete Migration: On startup, the migration script copies plaintext keys to the native store, reads them back to verify they match exactly, and only then deletes the plaintext version:
// src/lib/secrets.ts
export async function migrateSecretsFromLocalStorage(): Promise<void> {
if (typeof window === 'undefined' || !isTauri()) return;
for (const key of SECRET_KEYS) {
const legacyValue = localStorage.getItem(key);
if (legacyValue === null) continue;
try {
// 1. Write to OS Keychain
await invoke('set_secret', { key, value: legacyValue });
// 2. Read back and verify integrity
const verified = await invoke<string | null>('get_secret', { key });
if (verified === legacyValue) {
// 3. Only delete plaintext if verified!
localStorage.removeItem(key);
}
} catch (e) {
console.error(`Migration failed for ${key}`, e);
}
}
}
UI Input Optimization (onBlur vs. onChange)
In Web settings screens, it's common to save configuration fields onChange (on every keystroke). But with Keychain storage, doing this would trigger an OS keychain API write for every single character typed.
This causes noticeable lag and creates race conditions where async writes overwrite one another. We shifted saving to onBlur (when the input field loses focus), keeping state local during typing and writing to the hardware keychain only when the user finishes.
๐๏ธ 3. Tauri v2 Stable & Testing Safety Nets
Between v0.4.1 and v0.4.3, we upgraded our Tauri environment from the release candidates (tauri = "2.0.0-rc.17") to the official stable release, aligning it with the @tauri-apps/api stable packages.
Fixing the "Silent Crash" in Background Sync
In earlier releases, our background syncing was silently skipping runs. Because the WebView environment was detached from background workers, the sync loop failed to fetch credentials correctly.
To prevent regressions in our sync engine and data merge layers, we set up Vitest + JSDOM tests. We mocked the native Tauri bindings, simulating both browser and desktop runtimes:
// src/lib/secrets.test.ts
const { keychain, tauri } = vi.hoisted(() => ({
keychain: new Map<string, string>(),
tauri: { enabled: true },
}));
vi.mock('@tauri-apps/api/core', () => ({
isTauri: () => tauri.enabled,
invoke: vi.fn(async (cmd: string, args: { key: string; value?: string }) => {
if (cmd === 'set_secret') { keychain.set(args.key, args.value!); return null; }
if (cmd === 'get_secret') { return keychain.get(args.key) ?? null; }
if (cmd === 'delete_secret') { keychain.delete(args.key); return null; }
throw new Error(`unexpected command: ${cmd}`);
}),
}));
Having these unit tests run on every commit allowed us to refactor our authentication API (src/lib/auth.ts) to be fully asynchronous without introducing regressions in the frontend components.
๐ฏ Conclusion & Next Steps
Migrating to secure OS keychain storage and zero-knowledge WebDAV sync has turned Repo-rter into a robust, secure, and production-grade desktop client. Tauri v2's native capabilities allowed us to build these heavy security features with minimal friction.
You can inspect the entire implementation, download the installers, or self-host your sync backend here:
๐ Repo-rter GitHub Repository
I'd love to hear your thoughts in the comments! How do you handle sync and sensitive keys in your local-first apps? ๐ป๐
Top comments (0)