Skip to content
Documentation

Privacy architecture

The cryptographic stack, in detail. Every number here is also baked into the app's golden-vector tests; if it changes, this page changes in the same release. For the full version-pinned primitive list, audit status, and reproducible-build verification recipe, see /security.

The threat model

Tacita is built for a single user with a single phone. The threats we plan against are: the device is lost, stolen, or seized; a forensic recovery tool is run against the storage; an attacker reads the app's bundle and tries to extract data from a backup or from cloud sync. The protection is the encrypted vault.

What Tacita is not designed against: a kernel-level attacker on the user's phone while the vault is unlocked, or a compromise of the user's password itself. Both of those defeat any client-side encryption.

Key derivation: Argon2id (new vaults), PBKDF2-HMAC-SHA256 (legacy)

New vaults derive the master key with Argon2id (RFC 9106) using m = 64 MiB, t = 3, p = 4 and a 16-byte per-vault salt. Argon2id is the memory-hard password hash that NIST and OWASP recommend over older constructions; it is hostile to GPU/ASIC brute-force the way PBKDF2 is not. The output is a 32-byte AES-256 key. The parameters are calibrated to ~600–850 ms on a Galaxy A52-class Android.

  • Algorithm: Argon2id (RFC 9106)
  • Memory: 64 MiB
  • Iterations: 3
  • Parallelism: 4
  • Output: 32 bytes (AES-256 key)
  • Salt: 16 random bytes per vault
  • Library: package:cryptography 2.9.0

Vaults created before the Argon2id migration stay on PBKDF2-HMAC-SHA256 with 300,000 iterations until the user changes their password, at which point the master key is re-derived under Argon2id and every per-vault file is re-encrypted under the new key. The versioned envelope described below dispatches the read path automatically, so legacy and migrated vaults coexist without a one-shot migration. The .tacita.bak archive export format also stays on PBKDF2 — it is self-contained and bumping it would break restore-compatibility with older backups.

The salt is not secret — it just must not be lost, or the user is locked out forever. Tacita persists the salt in the platform secure store: Keychain on iOS, a Keystore-backed store on Android.

Versioned envelope: [0xCE][version][kdf_id][aead_id]

Every persisted ciphertext is prefixed with a 4-byte envelope header before the nonce. The decrypt path branches on the header bytes; legacy files written before the envelope landed are treated as version 0 (PBKDF2 + AES-GCM, the layout shipped before the Argon2id migration). The envelope is how Tacita migrates KDF and AEAD choices without forking the storage layer; planned post-launch work on double-ratchet session keys for the Bridge piggy-backs on the same mechanism.

Encryption at rest: AES-256-GCM

Every persisted file (chats, personas, settings, blobs) is encrypted with AES-256-GCM. Each record uses a fresh 12-byte random nonce and a 16-byte authentication tag.

  • Mode: AES-256-GCM
  • Nonce: 12 random bytes per record
  • AAD: none
  • File layout: [12B nonce][ciphertext][16B tag]

AAD is intentionally empty. The file path itself segregates per-user data, and binding the ciphertext to the path would block the forward-compatibility we need when the storage layout evolves.

Identity and key lifetime

The userId for a vault is sha256(password).hex(). It is used only to segregate per-user directories on disk — chats live under chats/{userId}/, settings under settings/{userId}/, blobs under blobs/{userId}/. The userId is never sent anywhere.

The master key is derived at unlock and lives only in a Riverpod KeyVault notifier in RAM. It is cleared on lock, on app background past a TTL, and on full app exit. It is never written to disk and never logged.

Forgotten passwords are unrecoverable. By design. Onboarding makes this unambiguous to the user. There is no recovery key, no password hint, no security question, and no operator-mediated reset.

secureDelete

Deletion in Tacita is not File.delete(). The secureDelete primitive performs one pass of overwrite with random bytes encrypted under an ephemeral key, then flushes, closes, and unlinks the file.

On copy-on-write flash filesystems (APFS on iOS, F2FS on many Android phones) the original blocks may briefly survive on the flash controller before garbage collection. That is fine. Those bytes are double-encrypted:

  1. By the overwrite key, which was never persisted.
  2. By the OS-level full-disk encryption — Android File-Based Encryption, or iOS Data Protection.

An attacker pulling the chip reads random noise on top of random noise. We do not add TRIM hacks: forced TRIM is unsupported on non-rooted Android and unreachable on iOS, and the threat model does not need it.

Per-vault segregation

Every user-scoped directory on disk is keyed by the userId. Wiping a vault means deleting all three per-user subtrees — chats, settings, and blobs — before forgetting the salt from secure storage.

Order matters. Chats reference blobs, so chats are wiped first. If the chat wipe throws, the blob wipe still proceeds — without it, encrypted-but-orphaned attachments would persist forever after the salt is forgotten and would leak per-vault attachment counts to anyone with file-system access.

Secret vaults

A user can create a vault that does not appear in the unlock screen list. It is reached only by typing its password directly. There is no "wrong password" feedback by design — an observer cannot tell whether a secret vault exists. The crypto contract is identical to a regular vault; the difference is the absence of the listing entry.

The blob store

Anything that is not JSON — image and video thumbnails, character avatars, CHARX assets — flows through a per-vault encrypted, content-addressed blob store. Bytes are keyed by SHA-256, encrypted with the vault key, and rendered back only through BlobImageProvider. Cleartext bytes never touch the disk. When a blob is missing or the vault is locked, the widget renders a placeholder; it does not fall back to a network fetch that would leak the source URL on every rebuild.

Backups

On Android, the app declares android:allowBackup="false" and a data-extraction-rules file that excludes the app's data from cloud backups and device-to-device transfer. On iOS, every persisted file is tagged with NSURLIsExcludedFromBackupKey, so iCloud never picks it up. The encrypted vault never appears in any cloud backup.

Logs

In release builds the app emits no logs at all. In debug builds — used only by the developer — logs are routed through a single helper that gates on the debug flag and never accepts message content as an argument: counts and durations only. No crash reporter ever runs; the OS-level crash log stays on the device and is not transmitted anywhere by Tacita.

What leaves the device

Three things, all initiated explicitly by the user:

  1. Model file downloads. When the user taps Install on a curated model or pastes a Hugging Face URL, the .gguf is fetched over HTTPS from the public host they chose. The request carries no identifier beyond what the OS sends for any HTTPS download.
  2. In-app purchase verification. When the user buys Tacita Pro, the receipt is verified by RevenueCat. RevenueCat receives an anonymous app-installation id and the platform receipt only — no chat content, no email, no name.
  3. Reachability check. A single optional check at startup so the Discover screen can tell the user it is offline.

After a model is on disk, every reply is computed locally. Inference never makes a network call.

Versioning policy

Any new ciphertext format gets a versioned envelope. The decrypt path branches on a version byte; layout is never silently mutated. The marketing site, the in-app legal screens, and the privacy-architecture page above all state the same crypto parameters. If any of those numbers change, every surface and the matching golden-vector tests change in the same release.


Related: the security primitive reference for the citable, version-pinned list with reproducible-build recipes, the privacy policy for the legal framing, the glossary for definitions of each primitive, and the FAQ for direct answers to common privacy questions.