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:
| Parameter | Value | Purpose |
|---|---|---|
cipher_page_size | 4096 | Page-level encryption granularity |
cipher_hmac_algorithm | HMAC_SHA256 | Per-page authentication |
cipher_kdf_algorithm | PBKDF2_HMAC_SHA256 | Internal SQLCipher KDF for page keys |
kdf_iter | 256000 | PBKDF2 iteration count |
journal_mode | WAL | Write-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):
| Context | Purpose |
|---|---|
pds v2 vault-key {profile_id} | SQLCipher page encryption |
pds v2 entry-encryption-key | Per-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-key | KEK 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:
- Page-level: SQLCipher encrypts the entire database page (key names, values, metadata) using the vault key via AES-256-CBC + HMAC-SHA256.
- Entry-level: Each value is individually encrypted with AES-256-GCM using the entry key
before being written to the
valuecolumn. 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 returnsNotFound.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:
- Validate that the vault key is exactly 32 bytes.
- Open the SQLite connection via
Connection::open(). - Set the encryption key via
PRAGMA keyin raw hex mode, then zeroize the hex string and the SQL statement. - Apply SQLCipher configuration PRAGMAs (cipher_page_size, HMAC algorithm, KDF algorithm, KDF iterations, WAL mode).
- Run the idempotent schema migration (
CREATE TABLE IF NOT EXISTS). - Verify the key by executing
SELECT count(*) FROM sqlite_master. - 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:
- Remove the profile from the
active_profilesauthorization set. This is the security-critical step and happens first, before any I/O. - Flush the JIT cache via
vault.flush().await. AllSecureBytesentries are zeroized on drop when theHashMapis cleared. - Call
pragma_rekey_clear()to scrub the C-level key buffer. - Drop the
SqlCipherStore. Theentry_key: SecureByteszeroizes on drop, andDropskips the redundantPRAGMA rekeybecauseclearedis already set. - Remove the master key from the
master_keysmap.SecureByteszeroizes on drop. - Remove any partial multi-factor unlock state for the profile.
- 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.