Database Security (Current Implementation)
This page explains how Cosmosh protects local database data today, why Linux may show safeStorage fallback errors, and what developers/operators should do to unblock startup safely.
1. Plain-English Mental Model
Think of Cosmosh database protection as a 2-step lock:
- Database key generation / recovery (done in Electron Main process).
- Database page encryption/decryption (done in Backend through a Prisma driver adapter backed by the SQLCipher native binding).
Main process decides where the real key comes from:
- Preferred path: OS-backed key protection via Electron
safeStorage. - Fallback path: master-password-based key derivation (for environments where
safeStorageis not available).
Backend does not invent a key in production. It expects the resolved key from Main via environment variable.
2. Scope and Threat Assumptions
2.1 What this model protects
- Encrypts production database pages at rest instead of only protecting the database key.
- Avoids directly storing raw database encryption keys in plain config in production mode.
- Keeps key bootstrap logic in Main process instead of Renderer.
2.2 What this model does not protect
- A fully compromised user session where attacker can read process memory.
- Unsafe operational handling (for example leaking fallback environment variables into logs or shell history).
- Cases where fallback metadata is missing and no user-facing master-password setup flow is available yet.
3. Runtime Modes and Key Sources
3.1 Development mode (!app.isPackaged)
- Main returns constant key
cosmosh_dev_key. - Backend also uses deterministic dev key behavior.
- Default DB location is workspace
.dev_data/cosmosh.db. - When a development profile is active through
pnpm dev:profileorCOSMOSH_DEV_PROFILE, DB location becomes.cosmosh/dev-profiles/<name>/database/cosmosh.db, and ElectronuserDataplus backend secret storage are isolated under the same profile root.
This is intentionally convenience-oriented, not production security.
3.2 Production packaged mode (app.isPackaged)
Main process calls getDatabaseEncryptionKey() and then injects result into backend env:
COSMOSH_DB_ENCRYPTION_KEY=<resolved key>- Backend reads it in
packages/backend/src/db/prisma.ts
If backend does not receive this key in production, it fails fast with [db:key] Missing COSMOSH_DB_ENCRYPTION_KEY ....
3.3 Schema ownership and startup policy
- Database schema is owned by Prisma workflows (
prisma db pushin dev, migrations in packaged/prod pipelines). - Backend startup validates required tables and fails fast if schema is missing, instead of creating tables via runtime hand-written SQL.
- Production Prisma clients use
PrismaSqlCipherAdapterFactoryfrompackages/backend/src/db/sqlcipher.ts. It composes Prisma's@prisma/adapter-better-sqlite3query/transaction contract with the native binding frombetter-sqlite3-multiple-ciphers. - The adapter applies
cipherandkeybefore returning the connection to Prisma, then readssqlite_masterthrough that same keyed connection. SQLCipher is therefore the actual Prisma storage path, not a separate preflight connection. - First-run migrations execute through the keyed adapter. After schema convergence, Backend rejects an empty file or a file exposing the plaintext
SQLite format 3\0header. - The SQLCipher native driver is mandatory in production. Missing/incompatible bindings or incorrect keys fail startup; there is no production plaintext Prisma fallback.
3.4 One-time legacy plaintext migration
Production startup recognizes only the canonical plaintext SQLite header as eligible for automatic migration. Unknown, corrupt, or encrypted files with an incorrect key are never treated as plaintext.
Migration sequence:
- Checkpoint the plaintext WAL and switch the source to
journal_mode=DELETE. - Copy the checkpointed source to
<database>.sqlcipher-migration. - Apply SQLCipher
rekeyonly to the copy. - Verify the encrypted copy with the resolved key,
integrity_check, and source/destination schema table counts. - Rename the source to
<database>.plaintext-backup, promote the verified encrypted copy, and verify the promoted database again. - Delete the temporary plaintext backup after successful promotion.
The fixed temporary/backup names make both rename interruption windows recoverable on the next startup. Before the encrypted copy verifies, the original plaintext database remains authoritative. If an interrupted encrypted temp cannot be verified, recovery preserves both artifacts and fails fast because it cannot safely distinguish corruption from an incorrect key; retrying with the original key can still promote the verified temp. Migration failures use DB_SQLCIPHER_MIGRATION_FAILED; SQLCipher/key/bootstrap failures use DB_SQLCIPHER_BOOTSTRAP_FAILED.
4. Preferred Path: Electron safeStorage
When safeStorage.isEncryptionAvailable() is true:
- Main reads
security.config.jsonunderapp.getPath('userData'). - If
encryptedDbMasterKeyexists:- Base64 decode →
safeStorage.decryptString(...). - Use decrypted plaintext as database key.
- Base64 decode →
- If it does not exist:
- Generate random 32-byte key (
randomBytes(32).toString('hex')). - Encrypt with
safeStorage.encryptString(...). - Store encrypted payload as
encryptedDbMasterKeyinsecurity.config.json.
- Generate random 32-byte key (
Important behavior:
- Stored value is encrypted blob, not plaintext key.
- Decryption is bound to OS secure storage availability.
- Main process performs encryption/decryption; renderer is not used for this path.
- If decryption succeeds and a plaintext emergency fallback field is present from an older or broken write path, Main removes that plaintext field as best-effort cleanup.
- If decryption or secure persistence fails at runtime, Main attempts fallback resolver instead of writing a plaintext emergency key while
safeStorageis available.
5. Fallback Path: Master Password Mode (When safeStorage Is Unavailable or Fails)
Main enters fallback resolver in all of these cases:
safeStorage.isEncryptionAvailable()isfalse.safeStorageis available butencryptedDbMasterKeydecryption fails.safeStorageis available but encrypt/persist fails while creating a new key.safeStoragepath is unavailable and emergency fallback key is available.
When fallback is entered due to unavailable safeStorage, Main logs:
[db:key] Electron safeStorage is unavailable. Falling back to master password mode.
Then it enters fallback resolver.
5.1 Required fallback metadata
security.config.json must contain:
masterPasswordHashmasterPasswordSalt
If masterPasswordHash is missing, startup throws:
secure storage unavailable and no master_password_hash found in config ...
5.2 Required fallback secret input
Environment variable required:
COSMOSH_DB_MASTER_PASSWORD
If password env or salt is missing, startup throws:
secure storage unavailable. Missing COSMOSH_DB_MASTER_PASSWORD or masterPasswordSalt ...
5.3 Verification + key derivation details
Fallback verification and derivation are currently:
- Verify hash:
scryptSync(password, salt, 32).toString('hex')compared with storedmasterPasswordHash. - Constant-time compare:
timingSafeEqual(...). - If verified, derive DB key:
scryptSync(password, salt, 32).toString('hex').
If hash check fails, startup throws:
master password verification failed in fallback mode.
5.4 Emergency fallback key path
To avoid startup dead-end only when safeStorage is unavailable, Main may persist an emergency local fallback key:
emergencyFallbackDbMasterKey?: string
Runtime behavior:
- If
safeStorageis unavailable and an emergency fallback key exists, use it directly. - If
safeStorageis unavailable and master-password fallback succeeds, persist emergency fallback key for future non-interactive recovery. - If
safeStorageis unavailable, no DB file exists, and fallback resolver fails, auto-provision a new emergency fallback key for first-run startup. - If
safeStorageis available, newly generated or recovered keys are persisted only asencryptedDbMasterKey. Main does not newly write a plaintext emergency fallback key in this mode.
If an existing DB file already exists and neither safeStorage nor fallback materials can recover old key material, startup still fails fast with explicit error to avoid silent data lockout.
5.5 Auto-migration when safeStorage recovers
If fallback successfully resolves a key while safeStorage is available again, Main will automatically:
- Encrypt the resolved fallback key with
safeStorage. - Persist it into
security.config.jsonasencryptedDbMasterKey. - Remove
emergencyFallbackDbMasterKeyfrom the config. - Continue startup using the same recovered key.
This avoids accidental key rotation and keeps previously encrypted database data readable after recovery.
5.6 Why this Linux error pattern appears
This error sequence usually indicates:
safeStorageunavailable on target Linux environment.- App entered fallback mode.
security.config.jsonlackedmasterPasswordHash(and/or related fallback metadata).- No completed renderer flow to collect and persist master-password metadata yet.
- Startup aborted by design to avoid using an unverified key.
The DBus/systemd line shown after that is usually side-effect noise from process lifecycle and does not change the root cause above.
5.7 Development Profile Isolation
Development profiles are intentionally convenience-oriented and always use the deterministic development key cosmosh_dev_key. They are for local verification of onboarding, first-run storage, settings defaults, and database bootstrap behavior.
Profile state lives under .cosmosh/dev-profiles/ and is ignored by Git:
.cosmosh/dev-profiles/state.json: current profile pointer written bypnpm dev:profile use <name>..cosmosh/dev-profiles/<name>/user-data: ElectronuserDataoverride..cosmosh/dev-profiles/<name>/database/cosmosh.db: SQLite database file injected throughCOSMOSH_DB_PATH..cosmosh/dev-profiles/<name>/backend-storage: backend secret storage injected throughCOSMOSH_BACKEND_STORAGE_PATH..cosmosh/dev-profiles/default/profile.json: manifest for the automatic legacy default import.
The first non-help pnpm dev:profile command imports the implicit legacy default identity into the managed default profile. The importer copies .dev_data/cosmosh.db plus SQLite -wal and -shm files when readable, copies the legacy Electron userData directory, and copies backend secret storage. Copy results are best-effort and are recorded in profile.json, so an unreadable legacy source creates an import=partial profile instead of deleting or mutating the original source.
Deleting or resetting a regular profile only affects that profile directory. The managed default profile rejects regular reset/delete commands; use pnpm dev:profile import-default --force to rebuild it from legacy sources. None of these commands touch the legacy development DB at .dev_data/cosmosh.db unless that file is manually removed outside the profile tool.
6. security.config.json Current Schema
Path:
- Production:
<userData>/security.config.json
Fields:
encryptedDbMasterKey?: string- Base64 encoded encrypted payload from
safeStoragepath.
- Base64 encoded encrypted payload from
emergencyFallbackDbMasterKey?: string- Plaintext emergency fallback key used only for availability-first recovery while
safeStorageis unavailable.
- Plaintext emergency fallback key used only for availability-first recovery while
masterPasswordHash?: string- Hex hash used only in fallback verification.
masterPasswordSalt?: string- Salt string used for verification and scrypt key derivation.
Notes:
- File can temporarily contain both safeStorage and fallback fields during recovery, but the
safeStoragepath removes plaintext emergency material after secure key resolution succeeds. - Fallback fields are required only when
safeStorageis unavailable. - Emergency fallback key can be used to repopulate
encryptedDbMasterKeyaftersafeStoragebecomes available again, then it is removed.
6.1 Prisma and SQLCipher Runtime Packaging
Packaged production runtime must include all parts of the single encrypted connection path:
@prisma/adapter-better-sqlite3and@prisma/driver-adapter-utilsat the same version as@prisma/client.- The
better-sqlite3JavaScript wrapper required by Prisma's adapter. - The filtered
better-sqlite3-multiple-ciphers/build/Release/better_sqlite3.nodenative binding.
packages/main/scripts/sync-backend-runtime.cjs recursively copies the adapter dependencies and separately preserves only the required SQLCipher native binding from the multi-cipher package.
The native binding has two explicit build targets. Development Main and standalone Backend commands run Backend under the workspace system Node, so their startup lifecycles probe that runtime and rebuild only after an ABI mismatch. Packaging always rebuilds for the exact installed Electron version before runtime synchronization. Every successful rebuild must pass an in-memory database open/close probe under its target runtime; file existence alone is not accepted as compatibility evidence. On Windows, stop processes using the current binding before switching targets because the loaded native file is locked.
Prisma Client, Prisma CLI, both adapter packages, and better-sqlite3-multiple-ciphers are pinned as one tested compatibility tuple. Upgrade them deliberately and rerun the encrypted reconnect, incorrect-key, plaintext-migration, and packaged-runtime tests on every supported platform before changing that tuple.
To avoid backend startup failures such as Prisma Client could not locate the Query Engine on target machines, Linux packaging must include these Prisma Linux targets:
debian-openssl-1.1.xdebian-openssl-3.0.x
CI validates required libquery_engine-*.so.node files via COSMOSH_REQUIRED_PRISMA_TARGETS during prebuild and fails fast when any required target is missing.
Runtime asset sync is platform-aware:
- Linux packages keep Linux
*.so.nodePrisma engines. - Windows packages keep Windows
*.dll.nodePrisma engines. - macOS packages keep Darwin
*.dylib.nodePrisma engines.
This prevents Linux compatibility binaries from being copied into Windows/macOS artifacts while preserving Linux fallback coverage.
7. Action Playbook for Linux Packaging
Until renderer-side “Set Master Password” flow is implemented end-to-end, use controlled operational fallback.
7.1 Immediate unblock checklist
- Choose a strong master password in secure operator workflow.
- Generate/store
masterPasswordSalt. - Compute
masterPasswordHash = scryptSync(password, salt, 32).toString('hex'). - Write both fields into
<userData>/security.config.json. - Set env
COSMOSH_DB_MASTER_PASSWORDbefore launching app. - Ensure env is not exposed in shell history/system logs where avoidable.
If any of the above is missing or mismatched, startup fails intentionally.
7.2 Operational cautions
- Do not commit fallback password, salt, or derived values to source control.
- Do not print fallback secret values in debug logs.
- Prefer one-time secret injection mechanisms over persistent plaintext env files.
8. Current Gaps and Planned Direction
Current gap:
- Error messages mention renderer IPC for “Set Master Password”, but renderer flow is not yet fully wired for production bootstrap in
safeStorage-unavailable environments.
Planned direction (implementation target, not yet complete):
- Add secure renderer-initiated master-password setup flow.
- Persist fallback metadata (
masterPasswordHash,masterPasswordSalt) through controlled IPC path. - Improve first-run UX when Linux secure storage is unavailable.
9. Troubleshooting Matrix
In desktop runtime, developers can inspect these diagnostics directly in Settings → Advanced → Database Encryption Info.
Symptom: safeStorage is unavailable
- Meaning: OS secure storage integration is not available in current runtime.
- Next step: verify fallback metadata +
COSMOSH_DB_MASTER_PASSWORD.
Symptom: no master_password_hash found in config
- Meaning: fallback verification metadata not provisioned.
- Next step: pre-provision
masterPasswordHashandmasterPasswordSalt.
Symptom: verification failed in fallback mode
- Meaning: provided password does not match hash/salt pair.
- Next step: verify password source, hash generation formula, and target config file path.
Symptom: backend says missing COSMOSH_DB_ENCRYPTION_KEY
- Meaning: main process did not successfully resolve key.
- Next step: inspect main-process earlier logs for safeStorage/fallback failure reason.
Symptom: DB_SQLCIPHER_MIGRATION_FAILED
- Meaning: a recognized plaintext SQLite database could not be checkpointed, encrypted, verified, or atomically promoted.
- Next step: preserve the primary database plus
.sqlcipher-migration/.plaintext-backupartifacts and inspect the nested cause before retrying.
Symptom: file is not a database after SQLCipher key application
- Meaning: the file is encrypted with another key, corrupt, or not a recognized plaintext SQLite database.
- Next step: verify
security.config.jsonand OS secure-storage identity. Do not delete or regenerate key material automatically.
10. Related Source Files
packages/main/src/security/database-encryption.tspackages/main/src/index.tspackages/backend/src/db/prisma.tspackages/backend/src/db/sqlcipher.tspackages/backend/src/db/sqlcipher.test.tsdocs/developer/core/architecture.md