Skip to content

Security and Privacy

Rafael edited this page Jul 15, 2026 · 1 revision

🔒 Security & Privacy

Threat model — what this protects, and what it doesn't

Being precise about scope matters more than the crypto primitives themselves:

  • What it protects against: someone who gains access to your disk — a stolen laptop, a leaked backup, a snapshot of ~/.config/auris uploaded somewhere it shouldn't be. Without your passphrase, your API keys, portfolios, and chat sessions (when storage encryption is on) are AES-256-GCM ciphertext, not readable JSON.
  • What it does not protect against: a compromised running Auris process (if malware is reading process memory while you're using the app, the derived key is in RAM), or whatever your configured LLM/market providers themselves see — a message you send to Claude, Gemini, or FMP is visible to that provider under their own privacy policy, same as using their API directly. Local-first means your storage is private by default; it doesn't make third-party API calls private.
  • Ollama is the one path that avoids the third-party-visibility question entirely — fully local inference, nothing leaves your machine for the LLM side of the conversation.

Credential encryption

API keys are encrypted at rest with AES-256-GCM. The key is derived from your passphrase via Argon2id — the exact tuning (pkg/config/crypto.go):

Parameter Value
Time (iterations) 1
Memory 64 MiB
Threads 4
Derived key length 32 bytes (AES-256)
Salt 16 bytes, cryptographically random
GCM nonce 12 bytes

A new random salt is generated on every Save for credential encryption — so the derived key material changes each time you save, even with the same passphrase. This is intentional and specific to credentials; it's not how portfolio/session encryption works (see below), and mixing the two up would be a bug, not a feature — which is exactly why they're two separate salt fields in the config.

Storage encryption (portfolios & sessions)

Since FEAT-16, portfolios and chat sessions can optionally get the same AES-256-GCM treatment — on by default for new setups (opt-out, not opt-in; toggle it any time from the menu, /encryption). This reuses the exact same Argon2id tuning as credentials, but deliberately does not reuse the credential salt or its rotate-on-every-save behaviour:

  • AurisConfig.StorageSalt is generated once, when storage encryption is first enabled, and stays stable across every subsequent save. Regenerating it the way the credential salt regenerates would silently change the derived key underneath every already-encrypted portfolio/session file on the very next unrelated config save — a self-inflicted, silent data-loss bug. Keeping it stable is the whole point.
  • The active storage key lives in package-level in-memory state (config.SetStorageKey / config.StorageKey), set on unlock and cleared on lock, rather than threaded as a parameter through every one of the many call sites that read/write portfolios and sessions across the TUI and agent tools. nil means "encryption off" — the previous, pre-FEAT-16 behaviour, unchanged.
  • Toggling encryption on/off, or changing your passphrase while it's enabled, synchronously re-encrypts every existing file immediately (portfolio.ReencryptAllPortfolios / config.ReencryptAllSessions) rather than waiting for each file's next natural save. This is deliberately safe to retry: for each file, the routine tries the old key first, then the new key if that fails — so a batch interrupted partway through (crash, disk full) leaves some files already migrated and some not, and simply running it again picks up exactly where it left off, without corrupting anything already migrated.

Mixed plaintext/encrypted files, without a migration pass

Detecting whether a given file on disk is encrypted or legacy plaintext doesn't rely on "try to parse JSON and assume encryption if that fails" — it uses an explicit envelope format:

{"encrypted": true, "data": "..."}

A legacy plaintext file simply doesn't have those keys, so it's detected as unencrypted and parsed directly — no separate migration step is needed, and encrypted and plaintext files can coexist on disk indefinitely (e.g., mid-toggle) without either code path getting confused.

Corruption safety: atomic writes

Every write of config, session, portfolio, and export data goes through config.WriteFileAtomic — write to a temp file in the same directory, then os.Rename over the real path. os.Rename within the same filesystem is an atomic operation at the OS level, so a crash or full disk mid-write can only ever leave an orphaned .tmp file behind; the real target path is always either the old complete content or the new complete content, never a half-written, unrecoverable AES-GCM blob (a partial GCM ciphertext can't be authenticated — it's not "mostly readable," it's just gone).

What's explicitly not encrypted

Your financial profile (the 10-question risk/goals questionnaire from Using the Agent) is always stored as plaintext JSON, never encrypted, by explicit design — it isn't a credential, and keeping it plaintext means the app can read it (to build system prompts) without needing your passphrase unlocked first. This is a documented boundary, not an oversight: if you wouldn't want your risk tolerance and investment goals readable on disk, that's worth knowing going in.

Summary

Data Encrypted at rest? Default
API keys / credentials Always —
Portfolios Optional (AES-256-GCM) On for new setups
Chat sessions Optional (AES-256-GCM) On for new setups
Financial profile Never (plaintext JSON) —

See FAQ & Troubleshooting for what happens if you forget your passphrase.

Clone this wiki locally