macOS desktop encryption at rest
**Applies to:** the unadvertised Desktop **embedded fallback** (SQLCipher SQLite in app data). The supported product path is Desktop → Connect to server → a compose or hosted instance; that instance uses Postgres and is not
encrypted by this Keychain scheme.
Security boundary#
The implementation assumes the Mac, its logged-in local user, and the running app are trusted. It protects a copied or offline local SQLite file from being read without the Mac Keychain root key. It does not protect against a compromised account or computer, a process able to inspect Doso, or an attacker operating as the user while the app is unlocked.
The root and derived keys are zeroized when their Rust owners are dropped, but the database and settings keys remain in process memory while the app runs. Locking the Mac prevents a new WhenUnlocked Keychain read; it does not retract keys already loaded into the process.
Key hierarchy and storage#
On first protected startup, the app generates a random 256-bit root key under Keychain service com.somervell.doso, account root-key-v1.
- Production policy: macOS Data Protection Keychain,
AccessibleWhenUnlockedThisDeviceOnly, with synchronization explicitly disabled. The item is therefore available only while the user session is unlocked, does not sync through iCloud Keychain, and is not migrated to another device. - HKDF-SHA-256 derives two independent 256-bit keys from the root. The domain labels are
doso/sqlcipher/v1for the database anddoso/settings/v1for settings. - The database key is supplied to SQLCipher as a raw hex key. The settings key encrypts only the
app_settings.secrets_ciphertextJSON with AES-256-GCM; non-secret settings remain normal database fields.
Unsigned debug builds can receive macOS errSecMissingEntitlement (-34018) when accessing the Data Protection Keychain. Debug builds then use a non-synchronizing item in the login Keychain so tauri dev remains usable. Every later startup tries the protected Keychain first: if it finds only the login-Keychain item, it validates and copies that same root into the protected Keychain once signing permits it, verifies the copy, and deletes the weaker login-Keychain item. Release builds do not use this fallback and fail if protected-Keychain access fails.
If both stores contain roots, cleanup occurs only when the values are identical. A mismatch preserves both items and fails startup for manual recovery.
Explicit rapid-development override#
Debug builds can bypass Keychain entirely by setting a stable root in the
gitignored .env:
1DOSO_DEV_ROOT_KEY_HEX=<64 lowercase or uppercase hex characters>Generate it once with openssl rand -hex 32 and keep the same value for that
development store. The override is intentionally constrained:
- It is rejected by release builds.
- It can create a missing SQLite store.
- It can reopen an encrypted store only when the configured key is correct; a wrong key fails without replacing or re-encrypting the file.
- It refuses to convert an existing plaintext store. Choose a fresh SQLite path for override-based development, or remove the override and use the normal Keychain migration.
- It bypasses both Data Protection and login Keychain access, and the key is
therefore only as protected as the local
.envfile.
Removing the variable does not re-key the database. A store created with the override still requires that same root; without it, normal Keychain startup fails closed because it will not generate a replacement for an encrypted file.
SQLCipher scope#
SQLCipher applies to file-backed SQLite opened by the macOS desktop app, including newly created files and SQLite sidecars generated after the protected connection opens. The connection verifies that SQLCipher is linked and that the database is readable with the supplied key.
It does not encrypt:
- Postgres storage; transport, server, volume, and backup encryption are the Postgres operator's responsibility.
- Arbitrary files in the app-data directory, including
storage.json. - SQLite used by the standalone API, Docker, mobile app, or headless tools, which retain their existing settings-key behavior.
The optional Git vault uses a separate, user-held passphrase / recovery key (see Git vault). That DEK is exportable by design so encrypted backups can move between machines; it is not the Keychain root. The vault is the versioned content plane, while SQLite/Postgres is the graph Cache; changing one does not migrate or replace the other. See data modes for that storage split.
Startup and plaintext migration#
Startup serializes encryption bootstrap with .encryption-bootstrap.lock, then classifies the configured local SQLite path as missing, plaintext (the standard 16-byte SQLite header), or protected/unknown.
For a plaintext database, migration is staged as follows:
- Rename a legacy
settings.keytosettings.key.migrating. The staged file is retained until all settings copies are successfully re-encrypted, so an interrupted migration can retry. - Acquire the per-database
<database>.migration.lock; a second migrator is rejected. - Checkpoint the plaintext WAL and require exclusive SQLite locking. An active writer or busy WAL causes startup to fail without converting the source.
- Export with
sqlcipher_exportinto a temporary encrypted database in the same directory and copyuser_version. - Reopen the temporary database with the derived key, run SQLCipher and SQLite integrity checks, flush it, remove plaintext
-wal,-shm, and-journalsidecars, replace the source by rename, and flush the parent directory. - Run normal schema migrations on the protected database.
- Re-encrypt
app_settings.secrets_ciphertextfrom the staged file key to the HKDF-derived settings key in a transaction. The operation is retry-safe: ciphertext already using the new key is accepted. - Delete
settings.key.migratingonly after all required settings copies succeed.
When Postgres is active, settings in the active Postgres database are re-encrypted. If the configured inactive local SQLite database already exists, that encrypted SQLite copy is opened and its settings are re-encrypted too. This avoids leaving the SQLite settings copy tied to the deleted file key after switching back from Postgres.
Migration and startup fail closed on lock contention, busy writers, export or integrity failures, unreadable databases, wrong keys, malformed root keys, or settings ciphertext that decrypts with neither the old nor new settings key. A protected/unknown local file with no Keychain root key never causes a replacement key to be generated.
Live storage switching#
The running desktop API keeps the SQLCipher connection policy when Apply storage changes the backend:
- A missing SQLite path may be created; its first pages are written through SQLCipher.
- An existing database must already open with the current derived key. In particular, an existing plaintext candidate is rejected as a bad candidate and is not migrated or mutated by live switching.
- Postgres connections are not passed a SQLCipher key.
Before switching, the candidate is connected and migrated, its settings are decrypted and validated, and the storage preference is saved. An empty destination receives a one-time copy of the current graph; a non-empty destination is left unchanged. Only then is the shared live pool replaced.
For vault sharing, remote Git, and encrypted-bundle recovery behavior, see the Git vault operator guide. Vault encryption uses its own exportable DEK and is outside this macOS SQLCipher lifecycle.
Recovery and key loss#
There is no root-key export, escrow, passphrase recovery, or key rotation flow. A database backup without its original Keychain root is not recoverable. Because the production item is device-only and non-synchronizing, moving the database to another Mac does not move the key.
If an encrypted local database exists and its Keychain item is deleted or inaccessible, startup refuses to create a new key. Restore access to the original Keychain item or restore a matching database/key environment; deleting or regenerating keys cannot decrypt the existing file. Postgres graph data is outside SQLCipher, but its settings secrets still require the original derived settings key.
Signing requirements#
Production builds must be code-signed and provisioned so macOS permits Data Protection Keychain access. The repository does not currently check in a macOS entitlements file or signing identity; those must be supplied by the release signing environment. make desktop invokes tauri build, but its output is production-safe only when the resulting app is properly signed and the protected-Keychain call succeeds.
Check a bundle before distribution:
1codesign --verify --deep --strict --verbose=2 "/path/to/Doso.app"2codesign -d --entitlements :- "/path/to/Doso.app"Run the signed app once while logged in and unlocked, then use Keychain Access to
inspect Local Items for service com.somervell.doso and account
root-key-v1 without revealing the secret. The security find-generic-password
CLI searches file-based keychains and can see the unsigned-development fallback;
it does not prove that the production item is in Data Protection Keychain.
Automated release verification should issue a signed SecItemCopyMatching query
with kSecUseDataProtectionKeychain=true.
Verification#
Run the focused SQLCipher and migration tests plus the desktop bootstrap tests:
1cargo test -p doso-core --features sqlcipher --test encryption_at_rest2cargo test -p doso-desktopThe focused suite covers HKDF separation, file classification, encrypted creation, wrong-key rejection, plaintext export, migration locking and active-writer rejection, non-mutating rejection during policy-based opens, and settings re-encryption. Desktop tests cover missing-key fail-closed behavior and legacy settings migration, including active Postgres/inactive SQLite copies.
For a manual local-file check, stop the app and inspect only the header:
1xxd -l 16 "/path/to/doso.db"An encrypted database must not begin with SQLite format 3. Header obfuscation alone is not a full integrity test; the automated suite also opens the file with the right and wrong keys and runs SQLCipher integrity checks during migration.