Secret Lifecycle
This page describes how secrets move through the system: from storage in an encrypted vault, through a JIT cache, across the IPC bus, and into a child process’s environment. It also covers key material lifecycle and the compliance testing framework.
Secret Storage Operations
The SecretsStore trait (core-secrets/src/store.rs) defines four operations that every storage
backend must implement:
| Operation | Behavior |
|---|---|
get(key) | Retrieve a secret by key. Returns an error if the key does not exist. |
set(key, value) | Store a secret. Overwrites if the key already exists. Updates updated_at; sets created_at on first insert. |
delete(key) | Delete a secret by key. Returns an error if the key does not exist. |
list_keys() | List all key names in the store. Values are not returned (no bulk decryption). |
The list_keys method intentionally avoids returning values. Listing secrets does not trigger
bulk decryption of every entry, limiting the window during which plaintext exists in memory.
Two implementations exist:
SqlCipherStore(core-secrets/src/sqlcipher.rs): Production backend. Eachsetencrypts the value with per-entry AES-256-GCM before writing to the database. Eachgetdecrypts after reading. TheMutex<Connection>serializes all database access.InMemoryStore(core-secrets/src/store.rs): Testing backend. Holds secrets in aHashMap<String, SecureBytes>protected by atokio::sync::RwLock. Values are stored asSecureBytes(mlock’d, zeroize-on-drop). Does not persist to disk.
JIT Cache
The JitDelivery<S> wrapper (core-secrets/src/jit.rs) adds a time-limited in-memory cache in
front of any SecretsStore implementation. It exists to avoid repeated SQLCipher decryption for
frequently accessed secrets.
Resolution
JitDelivery::resolve(key) checks the cache first. If a valid (non-expired) entry exists, the
cached SecureBytes clone is returned without touching the underlying store. If the entry is
missing or expired, the value is fetched from the store, cached, and returned.
Both the cache entry and the returned value are independent SecureBytes clones. Each
independently zeroizes on drop.
TTL Expiry
Each cache entry records its fetched_at timestamp as a std::time::Instant. On the next
resolve call, if fetched_at.elapsed() >= ttl, the cached value is considered expired and a
fresh fetch occurs. The default TTL is 300 seconds, configurable via the daemon’s --ttl flag
or PDS_SECRET_TTL environment variable.
The ttl_expiry_refetches test verifies that after TTL expiry, the underlying store is
re-queried and updated values are returned.
Flush on Lock
JitDelivery::flush() clears the entire cache by calling cache.clear(). Because each value
in the cache is a SecureBytes, dropping the HashMap entries triggers zeroization of all
cached secret material. Flush is called during profile deactivation and locking, before the
vault is closed and key material is destroyed.
The flush_clears_cache test verifies that after a flush, the next resolve call fetches
fresh data from the underlying store.
Store Bypass
JitDelivery::store() provides direct access to the underlying SecretsStore, bypassing the
cache. This is used for write operations (set, delete, list_keys) which should not
interact with the read cache. After a set or delete, the daemon calls
vault.flush().await to invalidate any stale cache entries.
Key Material Lifecycle
All key material in the secrets subsystem is held in SecureBytes (core-crypto), which
provides:
- mlock: The backing memory is locked to prevent swapping to disk. On Linux, this uses
memfd_secretwith guard pages when available. - Zeroize on drop: When a
SecureBytesvalue is dropped, its backing memory is overwritten with zeros before deallocation. This is implemented via thezeroizecrate’sZeroizetrait. - Clone independence: Cloning a
SecureBytesvalue creates a new mlock’d allocation. Dropping the clone does not affect the original, and vice versa.
The lifecycle of key material through the system:
- Derivation: The master key is derived via Argon2id from the user’s password and a
per-profile 16-byte salt (
derive_master_key()indaemon-secrets/src/unlock.rs, which delegates tocore_crypto::derive_key_argon2()). The result is a 32-byteSecureBytesvalue. - Storage: The master key is stored in
VaultState::master_keys(daemon-secrets/src/vault.rs), aHashMap<TrustProfileName, SecureBytes>. - Derivation (vault key): On first vault access,
core_crypto::derive_vault_key()derives a 32-byte vault key from the master key via BLAKE3. The intermediate stack array is wrapped inzeroize::Zeroizingand zeroized on scope exit. - Use: The vault key is passed to
SqlCipherStore::open(), which uses it forPRAGMA keyand derives the entry key. The vault key is not retained by the store after open completes. - Destruction: On lock or deactivation, the JIT cache is flushed (zeroizing cached
secrets),
pragma_rekey_clear()scrubs the C-level key buffer, theSqlCipherStoreis dropped (zeroizing the entry key), and the master key is removed from the map (zeroizing on drop).
Field-Level IPC Encryption
When the ipc-field-encryption feature is enabled, secret values are encrypted with AES-256-GCM
before being placed on the IPC bus, providing a second encryption layer on top of the Noise IK
transport.
The per-profile IPC encryption key is derived via
core_crypto::derive_ipc_encryption_key(master_key, profile_id) using the context string
"pds v2 ipc-encryption-key {profile_id}". The wire format is
[12-byte random nonce][AES-256-GCM ciphertext + tag].
This feature is gated behind ipc-field-encryption and disabled by default for the following
reasons, documented in daemon-secrets/src/vault.rs:
- The Noise IK transport is already the security boundary, matching the precedent set by ssh-agent, 1Password, Vault, and gpg-agent.
- CLI clients lack the master key needed to decrypt per-field encrypted values.
- The per-field key derives from the same master key that transits inside the Noise channel, so it is not an independent trust root.
When enabled, the encryption path in handle_secret_get (daemon-secrets/src/crud.rs) encrypts
values before sending the SecretGetResponse, and the decryption path in handle_secret_set
decrypts incoming values before writing to the vault. The decrypted intermediate Vec<u8> is
explicitly zeroized after the store write completes.
Compliance Testing
The compliance_tests() function (core-secrets/src/compliance.rs) defines a portable test
suite that every SecretsStore implementation must pass. The suite verifies:
| Test case | Assertion |
|---|---|
| Set and get | A stored value is retrievable with identical bytes. |
| Overwrite | Storing to an existing key replaces the value. |
| Get nonexistent | Retrieving a key that does not exist returns an error. |
| Delete | A deleted key is no longer retrievable. |
| Delete nonexistent | Deleting a key that does not exist returns an error. |
| List keys | All stored key names appear in the list. |
| Cleanup | After deleting all keys, the list is empty. |
The in_memory_store_passes_compliance test runs this suite against InMemoryStore. The
SQLCipher backend has its own compliance tests in core-secrets/src/sqlcipher.rs that
additionally verify encryption properties (no plaintext on disk, cross-profile isolation, nonce
uniqueness).
Six-Gate Security Pipeline
Every secret CRUD operation passes through a six-gate security pipeline in
daemon-secrets/src/crud.rs before the vault is accessed. The gates execute in order from
cheapest to most expensive:
- Lock check: Rejects the request if no profiles are unlocked (
master_keysis empty). - Active profile check: Rejects if the requested profile is not in the
active_profilesset. - Identity check: Logs the requester’s
verified_sender_name(stamped by the IPC bus server from the Noise IK registry). Expected requesters aredaemon-secrets,daemon-launcher, orNone(CLI relay). - Rate limit check: Applies per-requester token bucket rate limiting.
- ACL check: Evaluates per-daemon per-key access control rules from config.
5.5. Key validation: Validates the secret key name via
core_types::validate_secret_key(). - Vault access: Opens (or retrieves) the vault and performs the requested operation.
Each gate that denies a request emits both a structured tracing log entry and a
SecretOperationAudit IPC event (fire-and-forget to daemon-profile for persistent audit
logging). The denial response is sent immediately and processing stops.