Encryption
Cortexa encrypts at rest in two layers, both keyed from the vault passphrase:
- The whole database file (
cortexa.db: previews, summaries, the full-text index, tags, metadata, embeddings, jobs, API tokens, everything) is encrypted with SQLCipher under a random 256-bit DB key. - Content blobs and selected columns (thought bodies, attachments, briefing audio and scripts, theses, chat, Foundry state, sealed secrets) are additionally AES-256-GCM encrypted under the Argon2-derived vault key, so they stay encrypted when they leave the DB (sync, blob files).
The encryption passphrase lives in the OS keychain and never touches the app bundle or disk in plaintext.
App locking restricts access through Cortexa; the running app holds the keys in memory, so locking does not re-encrypt anything. A configured cloud provider receives the text needed for the operation the user selects. Hive sharing and public publishing follow the operator's chosen connection settings.
Cryptographic Primitives
| Component | Algorithm | Details |
|---|---|---|
| Symmetric cipher | AES-256-GCM | 256-bit key, 12-byte nonce, authenticated encryption |
| Key derivation | Argon2id | Default parameters from the argon2 crate |
| Nonce generation | CSPRNG | 12 random bytes per encryption via rand::thread_rng() |
| Salt | Random | 16 bytes, generated once per vault, stored in salt.bin |
| Key zeroization | zeroize crate | EncryptionKey implements Drop to wipe memory |
The Rust implementation lives in src-tauri/src/crypto.rs. The EncryptionKey struct wraps a [u8; 32] and implements Drop with zeroization so the raw key material is wiped from memory when the struct is dropped.
Key Derivation
On first launch, Cortexa generates a random 16-byte salt and writes it to salt.bin in the data directory. It also generates (or loads) a passphrase:
passphrase (from OS keychain) + salt.bin (16 bytes on disk)
|
Argon2id
|
256-bit EncryptionKeyThe derivation call:
Argon2::default().hash_password_into(passphrase, salt, &mut key)This uses the argon2 crate's default Argon2id parameters. The derived 32-byte key is used directly as the AES-256-GCM key for all encrypt/decrypt operations.
Passphrase Management
The passphrase is managed by src-tauri/src/key_manager.rs:
- Fresh install: A random 32-byte hex string is generated via
rand::thread_rng().fill_bytes()and stored in the OS keychain undercortexa/encryption-passphrase. - Existing install: The passphrase is read from the keychain on startup.
- Legacy migration: Installs that predate the keychain module are migrated from a hardcoded passphrase to the keychain automatically on first run.
- Debug builds: If
~/.cortexa-dev.keyexists with valid JSON containing apassphrasefield, that value is used instead of the keychain. This avoids repeated keychain prompts during development. Release builds never read this file.
The keychain integration uses the keyring crate, which maps to:
| OS | Backend |
|---|---|
| Windows | Windows Credential Manager |
| macOS | Keychain Services |
| Linux | Secret Service (GNOME Keyring / KWallet) |
Blob Encryption Flow
Every captured thought follows this path:
User types content
|
v
crypto::encrypt(key, plaintext)
|
v
[nonce(12 bytes) || ciphertext || GCM tag(16 bytes)]
|
v
Written to: blobs/<uuid>.binThe output layout of each encrypted blob is:
Byte 0..11 : Random nonce (12 bytes)
Byte 12..N : Ciphertext + 16-byte GCM authentication tagA fresh random nonce is generated for every encryption operation. Because AES-GCM nonces are 96 bits (12 bytes), the probability of nonce collision is negligible for any practical vault size.
Decryption splits the first 12 bytes as the nonce and passes the remainder to AES-GCM:
let (nonce_bytes, ciphertext) = blob.split_at(NONCE_LEN);
key.cipher().decrypt(nonce, ciphertext)If the GCM tag does not validate (wrong key, corrupted data, or tampered ciphertext), decryption returns an error immediately.
What Is Encrypted
| Data | Encrypted? | Location |
|---|---|---|
| Item content (primary blob) | Yes | blobs/<id>.bin |
| Full-text search body | DB file only | items_fts.content_body (SQLCipher) |
| Briefing audio (mp3) | Yes | audios/<id>.bin |
| Briefing scripts | Yes | briefings.script column (BLOB) |
| Thesis expansion / counterpoints | Yes | theses table (BLOB columns) |
| Sync manifest | Yes | Remote manifest.json |
| Attachment file data | Yes | blobs/<att-id>.bin |
| Item preview (first ~200 chars) | DB file only | items.preview column (SQLCipher) |
| Item summary | DB file only | items.summary column (SQLCipher) |
| Embeddings (float vectors) | DB file only | items.embedding column (SQLCipher) |
| Tags | DB file only | tags table (SQLCipher) |
| Jobs, API tokens, metadata | DB file only | SQLite tables (SQLCipher) |
| DB key | Yes (wrapped) | dbkey.bin |
| Settings / provider API keys | No | settings.json |
"DB file only" means the value is plaintext inside the database but the database file (and its WAL) is SQLCipher-encrypted on disk. It is readable by the running app and by the --vault-query tool, not by anyone holding a copy of the file.
Database Encryption (SQLCipher)
cortexa.db is a SQLCipher database (bundled through rusqlite's bundled-sqlcipher feature; SQLCipher 4 defaults: AES-256-CBC pages, HMAC-SHA512 per page, 4096-byte pages). Nothing in it is readable without the DB key, including the WAL.
Key design. The DB key is 32 random bytes, generated once per vault, and never derived from the passphrase:
passphrase + salt.bin --Argon2id--> vault key
vault key --AES-256-GCM unwrap--> dbkey.bin --> DB key --PRAGMA key = "x'…'"--> cortexa.dbdbkey.bin=CXDBKEY1magic + AES-256-GCM(vault key, DB key). It sits next tosalt.binand travels with it: backups carry both, and a restore needs both.- The raw-key form (
x'…') skips SQLCipher's own PBKDF2: the vault key already came out of Argon2id, and a second slow KDF would only slow every open. SQLCipher's page cipher and HMAC settings are left at their defaults. - A passphrase change re-wraps
dbkey.bin; it never re-encrypts the DB file. The re-wrap is staged asdbkey.rekey.binalongside the staged blob dirs and swapped in byfinish_rekeyafter the DB commit, so the existing crash-safe protocol (commit marker + canary + pending keychain entry) covers it. During a change either passphrase can unwrap one of the two files, so startup can always read the commit marker.
Why not derive the DB key from the vault key (HKDF)? A passphrase change would then have to rewrite the entire DB with PRAGMA rekey, which is neither fast nor crash-safe on a large vault, and every backup would need re-encrypting to stay openable. Wrapping costs one 60-byte file.
Verification. A wrong key fails at the first page read, so the startup probe (probe_vault_key) checks three things in order: the vault key must unwrap dbkey.bin (else passphrase mismatch), that key must decrypt the DB (else verification failed), and the key-check canary in vault_meta must authenticate (a second, independent check on the vault key). An encrypted DB with no dbkey.bin enters the recovery state (missing_db_key); Cortexa never generates a fresh key for an existing DB.
Every connection is keyed: the store, the job queue's second connection (opened only after the key is verified; a stand-in until then), the backup snapshot and its destination, restore validation, and rekey. The Hive relay's hive.db is a different database and stays plain SQLite.
One-time migration of older vaults
A vault written before SQLCipher (plaintext cortexa.db, recognizable by its SQLite format 3 header) is migrated once, only after the vault key is verified (never in the recovery state). It runs in the background once the app is up (vault_migration.rs), not in the startup hook, because on a large vault it takes too long for a window that can't paint: the app opens the plaintext vault as it is, then takes it through the same quiesce as a restore (maintenance on, store parked on the in-memory placeholder, job queue detached) and shows an "Encrypting your vault… this happens once" screen with a progress bar until it finishes. The API and IPC stay responsive throughout (the store lock is never held across the export).
Measured on a copy of a real 2.45 GB vault (2.4 GB of it inline audio), release build, NVMe SSD: export 28.0 s, integrity_check 20.6 s, row counts 0.2 s, fsync + swap + first keyed open < 0.1 s, zero-and-delete of the plaintext 1.2 s; about 50 s in total, peak disk 4.9 GB (2× the DB). An 800 MB vault took 12 s.
- Require free disk of at least 2× the DB (+WAL) size. Otherwise skip, keep the vault usable unencrypted, log it and show the user a notice; the next start retries.
- Create
dbkey.bin, checkpoint the WAL, thenATTACH 'cortexa.db.sqlcipher-staging' AS enc KEY …; SELECT sqlcipher_export('enc')and copyuser_version. - Verify the copy:
integrity_check, sameuser_version, identical row counts for every table. Any failure deletes the staging file and leaves the vault as it was. - With every connection closed, swap:
cortexa.db→cortexa.db.pre-sqlcipher(its WAL alongside), staging →cortexa.db. - After the next successful keyed open, the plaintext copy is overwritten with zeros and deleted. On SSDs and copy-on-write filesystems overwriting is best effort; the old blocks may survive until the drive reuses them.
Crash recovery needs no key and no marker file: startup looks at which files exist. A staging file next to a plaintext cortexa.db is an unfinished export (deleted); cortexa.db.pre-sqlcipher with no cortexa.db means the swap was cut between its renames, finished if the (already verified) staging copy exists and rolled back otherwise.
Old backups and pre-restore-* folders made before the migration still contain the plaintext database. Delete them once you have a new backup.
Inspecting your own vault
A plain sqlite3 CLI can no longer open cortexa.db (e.g. to look at the jobs table). Use the app binary instead, which resolves the key exactly as the app does, opens the DB read-only and never prints the key:
# Windows: run cortexa.exe from its install folder. Release builds have no
# console, so write the result to a file with --out.
cortexa.exe --vault-query "SELECT status, kind, count(*), last_error FROM jobs GROUP BY 1, 2, 4" --out jobs.tsv
# macOS
/Applications/Cortexa.app/Contents/MacOS/cortexa --vault-query "SELECT count(*) FROM items"Output is tab-separated with a header row; blobs print as <blob N bytes>. --json prints an array of row objects instead (blobs as {"blob_bytes": N}), and each --param V binds the next ? as text, so scripts never splice values into SQL. The demo capture scripts in marketing/ read the vault this way (marketing/proofreel/core/vault-db.mjs; set CORTEXA_BIN if the app isn't installed in a standard place). Writes are refused (read-only connection, query_only). --data-dir DIR points it at another data folder. The passphrase comes from the OS keychain, so it only works for the user who owns the vault.
Why Embeddings Are Not Separately Encrypted
Embedding vectors are stored as raw float arrays in the items.embedding BLOB column without their own AES-GCM layer (they are inside the SQLCipher-encrypted DB file). This is a deliberate design choice:
Derived data: Embeddings are generated from content by an LLM. They are a lossy, high-dimensional representation rather than the original text, but they can still reveal information about the source and should be treated as sensitive derived data.
Performance: The HNSW index loads all embeddings into memory on startup. Decrypting thousands of vectors on every launch would add significant latency.
Migration simplicity: Migration 3 (
PRAGMA user_version = 3) actually strips encryption from previously-encrypted embeddings, decrypting them in place with the old key and writing back the raw bytes.
On disk they are protected by the database encryption; in memory the HNSW index holds them in the clear while the app runs.
Constant-Time Key Comparison
The EncryptionKey::matches() method uses a constant-time XOR comparison to prevent timing side-channel attacks when verifying a re-derived key (e.g., during unlock):
pub fn matches(&self, other: &EncryptionKey) -> bool {
let mut acc = 0u8;
for (a, b) in self.0.iter().zip(other.0.iter()) {
acc |= a ^ b;
}
acc == 0
}This is used by the idle-lock unlock flow to verify the user's passphrase without leaking information about partial key matches through timing differences.
Change Passphrase Flow
The passphrase change (re-key) operation is handled by Store::rekey():
- Collect all blob paths from the
itemstable. - Decrypt every blob into memory using the old key. If any decryption fails, the operation aborts before writing anything -- disk state remains untouched.
- Re-encrypt every blob with the new key and write back to disk.
- Update the in-memory key to the new one.
- Write the new passphrase to the OS keychain.
This is an all-or-nothing operation: the full decrypt pass runs first so that a failure midway through re-encryption does not leave the vault in a mixed-key state.
Old passphrase + salt → Argon2 → Old key (decrypt all)
↓
Plaintext in memory
↓
New passphrase + salt → Argon2 → New key (encrypt all)
↓
Overwrite blob filesThe salt (salt.bin) does not change during a passphrase rotation. Only the passphrase changes, producing a different derived key.
Sync and Encryption
All sync backends (filesystem and S3-compatible) only ever see ciphertext:
- Blobs are uploaded as-is (already encrypted on disk).
- The manifest (
manifest.json) is encrypted with the same AES-256-GCM key before upload and decrypted after download. vault.jsoncontains only the KDF algorithm name and the salt hex -- no secrets. This file allows a second device to derive the same key from the user's passphrase.
A wrong passphrase during the "Connect to existing vault" flow is detected by attempting to decrypt the remote manifest. If decryption fails, the connection is rejected before any local state is modified.
Security Considerations
- Idle lock: When enabled, the in-memory key is gated (not zeroed -- full zeroization is a future hardening pass). All data-touching code paths check the lock state and return
vault is lockedwhile gated. - API key storage: Provider API keys (Anthropic, OpenAI, etc.) currently live in
settings.jsonin plaintext. The encryption passphrase and global API token are in the keychain. Migrating all secrets to the keychain is a planned improvement. - Provider egress: Local Ollama operations stay local. When a cloud chat, embedding, or TTS provider is selected, Cortexa sends it the text or media input needed for that operation.
- Sharing and publishing: Hive transfer and public pages follow each connection's configured direction, filters, auto-share and auto-publish settings. Publishing is a deliberate public disclosure path.
- Backup files:
.tar.gzand.jsonbackups contain the encrypted blobs, the SQLCipher-encrypted database (copied page by page under the same DB key, never decrypted),dbkey.bin,salt.binandsettings.json(which is not encrypted and holds provider keys; see above). The vault passphrase is required to open a restored backup. Backups made before the SQLCipher migration contain a plaintext database. - Key fingerprint:
EncryptionKey::fingerprint()returns the first 4 bytes of SHA-256(key) as hex, used for UI display ("locked with key abcd1234") without exposing any key material.