Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Vault Engine

The vault engine provides encrypted per-profile secret storage backed by SQLCipher databases with a multi-layer key hierarchy derived from BLAKE3.

SQLCipher Configuration

Each vault database is a SQLCipher-encrypted SQLite file. The following PRAGMA directives are applied in SqlCipherStore::open() (core-secrets/src/sqlcipher.rs) before any table access:

ParameterValuePurpose
cipher_page_size4096Page-level encryption granularity
cipher_hmac_algorithmHMAC_SHA256Per-page authentication
cipher_kdf_algorithmPBKDF2_HMAC_SHA256Internal SQLCipher KDF for page keys
kdf_iter256000PBKDF2 iteration count
journal_modeWALWrite-ahead logging for crash safety

SQLCipher encrypts every database page with AES-256-CBC and authenticates each page with HMAC-SHA256. The page encryption key is supplied via PRAGMA key as a raw 32-byte hex-encoded value. After the key pragma executes, both the hex string and the SQL statement are zeroized in place via zeroize::Zeroize before any further operations proceed.

Key Hierarchy

The key derivation chain from user password to on-disk encryption proceeds through three stages:

User password + per-profile 16-byte random salt
    --> Argon2id
    --> Master Key (32 bytes, held in SecureBytes / mlock'd memory)
        --> BLAKE3 derive_key(context="pds v2 vault-key {profile_id}")
        --> Vault Key (32 bytes) -- used as SQLCipher PRAGMA key
            --> BLAKE3 derive_key(context="pds v2 entry-encryption-key")
            --> Entry Key (32 bytes) -- used for per-entry AES-256-GCM

BLAKE3’s derive_key mode accepts a context string that provides domain separation. The vault key and entry key share the same vault key as input keying material but use different context strings, making them cryptographically independent. The vault_key_derivation_domain_separation test in core-secrets/src/sqlcipher.rs verifies this property.

The full set of derived keys sharing the same master key (defined in core-crypto/src/hkdf.rs):

ContextPurpose
pds v2 vault-key {profile_id}SQLCipher page encryption
pds v2 entry-encryption-keyPer-entry AES-256-GCM (derived from vault key, not master key)
pds v2 clipboard-key {profile_id}Clipboard encryption
pds v2 ipc-auth-token {profile_id}IPC authentication
pds v2 ipc-encryption-key {profile_id}Per-field IPC encryption (feature-gated)
pds v2 key-encrypting-keyKEK for platform keyring storage

An HKDF-SHA256 alternative is available via derive_vault_key_with_algorithm(), selectable per the HkdfAlgorithm enum. BLAKE3 is the default. The blake3_and_hkdf_sha256_produce_different_keys test confirms the two algorithms produce different outputs for the same inputs.

Double Encryption

Each secret value receives two independent layers of encryption:

  1. Page-level: SQLCipher encrypts the entire database page (key names, values, metadata) using the vault key via AES-256-CBC + HMAC-SHA256.
  2. Entry-level: Each value is individually encrypted with AES-256-GCM using the entry key before being written to the value column. The wire format stored in the database is [12-byte random nonce][ciphertext + 16-byte GCM tag].

Every encryption operation generates a fresh 12-byte random nonce via getrandom. The minimum wire length for decryption is 28 bytes (12-byte nonce + 16-byte GCM tag); shorter values are rejected with an error. The encrypt_same_value_produces_different_ciphertext test verifies nonce uniqueness across 100 consecutive encryptions of identical plaintext.

The db_file_contains_no_plaintext and db_file_contains_no_key_names_in_plaintext tests read raw database file bytes and assert that neither secret values nor key names appear anywhere in the on-disk file.

Database Schema

The schema is created via an idempotent CREATE TABLE IF NOT EXISTS statement during SqlCipherStore::open():

CREATE TABLE IF NOT EXISTS secrets (
    key        TEXT PRIMARY KEY,
    value      BLOB NOT NULL,
    created_at INTEGER NOT NULL,
    updated_at INTEGER NOT NULL
);

Timestamps are stored as Unix epoch seconds via SystemTime::now(). The schema_migration_idempotent test verifies that opening a database multiple times does not fail or corrupt existing data. After schema creation, SqlCipherStore::open() executes SELECT count(*) FROM sqlite_master to verify the key is correct; a wrong key causes this statement to fail with “wrong key or corrupt database”.

Per-Profile Vault Isolation

Each profile receives a separate database file at {config_dir}/vaults/{profile_name}.db and a separate 16-byte random salt file at {config_dir}/vaults/{profile_name}.salt. The salt is generated by getrandom on first unlock and persisted to disk by generate_profile_salt() in daemon-secrets/src/unlock.rs.

Isolation is cryptographic, not namespace-based. core_crypto::derive_vault_key(master_key, profile_id) uses the context string "pds v2 vault-key {profile_id}", producing a different 32-byte key for each profile ID even when the master key is the same. Attempting to open a vault encrypted with profile A’s key using profile B’s key fails at the SELECT count(*) FROM sqlite_master verification step.

Tests in core-secrets/src/sqlcipher.rs that verify isolation:

  • cross_profile_keys_are_independent – different profile IDs yield different keys, and opening a database with the wrong profile’s key fails.
  • cross_profile_secret_access_returns_error – reading a key from the wrong profile’s vault returns NotFound.
  • different_vault_keys_cannot_access – a database opened with key A rejects key B.

Vault Lifecycle

Create

A vault is created implicitly on first access after a profile is unlocked. VaultState::vault_for() in daemon-secrets/src/vault.rs creates the {config_dir}/vaults/ directory if needed, then calls SqlCipherStore::open() inside tokio::task::spawn_blocking with a 10-second timeout to avoid blocking the async event loop during synchronous SQLCipher I/O. The timeout is a defensive measure: if the blocking thread is killed (e.g., by seccomp SIGSYS), the JoinHandle would hang indefinitely without it. The opened store is wrapped in JitDelivery with the configured TTL (default 300 seconds, set via the --ttl CLI flag or PDS_SECRET_TTL environment variable).

Open

SqlCipherStore::open() performs the following steps in order:

  1. Validate that the vault key is exactly 32 bytes.
  2. Open the SQLite connection via Connection::open().
  3. Set the encryption key via PRAGMA key in raw hex mode, then zeroize the hex string and the SQL statement.
  4. Apply SQLCipher configuration PRAGMAs (cipher_page_size, HMAC algorithm, KDF algorithm, KDF iterations, WAL mode).
  5. Run the idempotent schema migration (CREATE TABLE IF NOT EXISTS).
  6. Verify the key by executing SELECT count(*) FROM sqlite_master.
  7. Derive the entry key via blake3::derive_key("pds v2 entry-encryption-key", vault_key), then zeroize the intermediate byte array.

Rekey (C-level Key Scrub)

SqlCipherStore::pragma_rekey_clear() issues PRAGMA rekey = '' to scrub SQLCipher’s internal C-level copy of the page encryption key from memory. This is defense-in-depth: the Rust-side entry_key: SecureBytes already zeroizes on drop, but this call ensures the C library’s internal buffer is also cleared. The method logs a warning on failure but does not panic, since the connection may already be in a broken state. The pragma_rekey_clear_does_not_remove_encryption test confirms this scrubs the in-memory key without removing on-disk encryption.

An AtomicBool (cleared) prevents redundant PRAGMA rekey calls in the Drop implementation.

Close

Vault closing occurs during profile deactivation (VaultState::deactivate_profile()) or locking (handle_lock_request()). The sequence is:

  1. Remove the profile from the active_profiles authorization set. This is the security-critical step and happens first, before any I/O.
  2. Flush the JIT cache via vault.flush().await. All SecureBytes entries are zeroized on drop when the HashMap is cleared.
  3. Call pragma_rekey_clear() to scrub the C-level key buffer.
  4. Drop the SqlCipherStore. The entry_key: SecureBytes zeroizes on drop, and Drop skips the redundant PRAGMA rekey because cleared is already set.
  5. Remove the master key from the master_keys map. SecureBytes zeroizes on drop.
  6. Remove any partial multi-factor unlock state for the profile.
  7. On Linux, delete the profile’s platform keyring entry via keyring_delete_profile().

On lock-all (no profile specified in LockRequest), the rate limiter state is also reset to a fresh SecretRateLimiter instance.