Skip to main content

zlayer_secrets/
persistent.rs

1//! Persistent secrets storage using `SQLite` (via `SQLx`).
2//!
3//! Provides encrypted local storage for secrets with a single table combining
4//! secret values and metadata:
5//! - `storage_key`: Primary key in `{scope}:{name}` format
6//! - `encrypted_value`: XChaCha20-Poly1305 encrypted secret bytes
7//! - `name`, `version`, `created_at`, `updated_at`: Metadata fields
8//!
9//! # Example
10//!
11//! ```no_run
12//! use zlayer_secrets::{EncryptionKey, PersistentSecretsStore, Secret};
13//! use zlayer_secrets::{SecretsProvider, SecretsStore};
14//!
15//! # async fn example() -> zlayer_secrets::Result<()> {
16//! let key = EncryptionKey::generate();
17//! let secrets_dir = zlayer_paths::ZLayerDirs::system_default().secrets();
18//! let store = PersistentSecretsStore::open(&secrets_dir, key).await?;
19//!
20//! // Store a secret
21//! let secret = Secret::new("my-password");
22//! store.set_secret("deployment/myapp", "db-password", &secret).await?;
23//!
24//! // Retrieve a secret
25//! let retrieved = store.get_secret("deployment/myapp", "db-password").await?;
26//! # Ok(())
27//! # }
28//! ```
29
30use std::collections::HashMap;
31use std::path::Path;
32
33use async_trait::async_trait;
34use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};
35use sqlx::{Row, SqlitePool};
36use tracing::{debug, info};
37
38use crate::{
39    EncryptionKey, Result, Secret, SecretMetadata, SecretsError, SecretsProvider, SecretsStore,
40};
41
42/// Default database filename when a directory is provided.
43const DEFAULT_DB_FILENAME: &str = "secrets.sqlite";
44
45/// SQL schema for the secrets table.
46const SCHEMA: &str = r"
47CREATE TABLE IF NOT EXISTS secrets (
48    storage_key TEXT PRIMARY KEY NOT NULL,
49    encrypted_value BLOB NOT NULL,
50    name TEXT NOT NULL,
51    version INTEGER NOT NULL DEFAULT 1,
52    created_at TEXT NOT NULL,
53    updated_at TEXT NOT NULL
54);
55CREATE INDEX IF NOT EXISTS idx_secrets_prefix ON secrets(storage_key);
56";
57
58/// Persistent secrets store backed by `SQLite` with encryption.
59///
60/// Secrets are encrypted using XChaCha20-Poly1305 before storage.
61/// Metadata is stored alongside secrets for inspection and auditing.
62///
63/// The store uses `SQLite` with WAL mode for concurrent access.
64pub struct PersistentSecretsStore {
65    pool: SqlitePool,
66    key: EncryptionKey,
67}
68
69impl PersistentSecretsStore {
70    /// Opens or creates a persistent secrets store at the given path.
71    ///
72    /// If `path` is a directory, the database file will be created as
73    /// `secrets.sqlite` inside that directory. If `path` is a file path,
74    /// it will be used directly.
75    ///
76    /// # Arguments
77    ///
78    /// * `path` - Path to the database file or directory
79    /// * `key` - Encryption key for encrypting/decrypting secrets
80    ///
81    /// # Errors
82    ///
83    /// Returns `SecretsError::Storage` if:
84    /// - The database cannot be created or opened
85    /// - Schema initialization fails
86    pub async fn open(path: impl AsRef<Path>, key: EncryptionKey) -> Result<Self> {
87        let path = path.as_ref();
88
89        // If the path is an existing directory, append the default database filename
90        let db_path = if path.is_dir() {
91            path.join(DEFAULT_DB_FILENAME)
92        } else {
93            path.to_path_buf()
94        };
95
96        // Ensure parent directory exists
97        if let Some(parent) = db_path.parent() {
98            std::fs::create_dir_all(parent)
99                .map_err(|e| SecretsError::Storage(format!("Failed to create directory: {e}")))?;
100        }
101
102        // Configure SQLite connection with WAL mode for better concurrency
103        let options = SqliteConnectOptions::new()
104            .filename(&db_path)
105            .create_if_missing(true)
106            .journal_mode(SqliteJournalMode::Wal)
107            .synchronous(SqliteSynchronous::Normal)
108            .busy_timeout(std::time::Duration::from_secs(30));
109
110        // Create connection pool
111        let pool = SqlitePoolOptions::new()
112            .max_connections(5)
113            .connect_with(options)
114            .await
115            .map_err(|e| {
116                SecretsError::Storage(format!(
117                    "Failed to open database at {}: {e}",
118                    db_path.display()
119                ))
120            })?;
121
122        // Initialize schema
123        sqlx::query(SCHEMA)
124            .execute(&pool)
125            .await
126            .map_err(|e| SecretsError::Storage(format!("Failed to initialize schema: {e}")))?;
127
128        info!("Opened persistent secrets store at {}", db_path.display());
129
130        Ok(Self { pool, key })
131    }
132
133    /// Constructs a storage key from scope and name.
134    ///
135    /// Format: `{scope}:{name}`
136    #[inline]
137    fn make_key(scope: &str, name: &str) -> String {
138        format!("{scope}:{name}")
139    }
140
141    /// Get the current timestamp as ISO 8601 string.
142    #[allow(clippy::cast_possible_wrap)]
143    fn now_iso8601() -> String {
144        let now = std::time::SystemTime::now()
145            .duration_since(std::time::UNIX_EPOCH)
146            .unwrap_or_default()
147            .as_secs();
148        // Convert to ISO 8601 format for SQLite TEXT storage
149        chrono::DateTime::from_timestamp(now as i64, 0).map_or_else(
150            || "1970-01-01T00:00:00Z".to_string(),
151            |dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
152        )
153    }
154
155    /// Parse ISO 8601 timestamp to Unix timestamp.
156    fn parse_timestamp(s: &str) -> i64 {
157        chrono::DateTime::parse_from_rfc3339(s)
158            .map(|dt| dt.timestamp())
159            .unwrap_or(0)
160    }
161}
162
163#[async_trait]
164impl SecretsProvider for PersistentSecretsStore {
165    async fn get_secret(&self, scope: &str, name: &str) -> Result<Secret> {
166        let storage_key = Self::make_key(scope, name);
167
168        let row = sqlx::query("SELECT encrypted_value FROM secrets WHERE storage_key = ?")
169            .bind(&storage_key)
170            .fetch_optional(&self.pool)
171            .await
172            .map_err(|e| SecretsError::Storage(format!("Failed to query secret: {e}")))?;
173
174        match row {
175            Some(row) => {
176                let encrypted_value: Vec<u8> = row
177                    .try_get("encrypted_value")
178                    .map_err(|e| SecretsError::Storage(format!("Failed to read value: {e}")))?;
179
180                let decrypted = self.key.decrypt(&encrypted_value)?;
181
182                let value = String::from_utf8(decrypted)
183                    .map_err(|e| SecretsError::Decryption(format!("Invalid UTF-8: {e}")))?;
184
185                debug!("Retrieved secret: {}", storage_key);
186                Ok(Secret::new(value))
187            }
188            None => Err(SecretsError::NotFound {
189                name: name.to_string(),
190            }),
191        }
192    }
193
194    async fn get_secrets(&self, scope: &str, names: &[&str]) -> Result<HashMap<String, Secret>> {
195        let mut results = HashMap::with_capacity(names.len());
196
197        for name in names {
198            // For batch retrieval, we silently skip missing secrets
199            // rather than returning an error
200            if let Ok(secret) = self.get_secret(scope, name).await {
201                results.insert((*name).to_string(), secret);
202            }
203        }
204
205        Ok(results)
206    }
207
208    async fn list_secrets(&self, scope: &str) -> Result<Vec<SecretMetadata>> {
209        let prefix = format!("{scope}:%");
210
211        let rows = sqlx::query(
212            "SELECT name, version, created_at, updated_at FROM secrets WHERE storage_key LIKE ? ORDER BY name",
213        )
214        .bind(&prefix)
215        .fetch_all(&self.pool)
216        .await
217        .map_err(|e| SecretsError::Storage(format!("Failed to list secrets: {e}")))?;
218
219        let mut results = Vec::with_capacity(rows.len());
220
221        for row in rows {
222            let name: String = row
223                .try_get("name")
224                .map_err(|e| SecretsError::Storage(format!("Failed to read name: {e}")))?;
225            let version: i64 = row
226                .try_get("version")
227                .map_err(|e| SecretsError::Storage(format!("Failed to read version: {e}")))?;
228            let created_at: String = row
229                .try_get("created_at")
230                .map_err(|e| SecretsError::Storage(format!("Failed to read created_at: {e}")))?;
231            let updated_at: String = row
232                .try_get("updated_at")
233                .map_err(|e| SecretsError::Storage(format!("Failed to read updated_at: {e}")))?;
234
235            #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
236            results.push(SecretMetadata {
237                name,
238                version: version as u32,
239                created_at: Self::parse_timestamp(&created_at),
240                updated_at: Self::parse_timestamp(&updated_at),
241            });
242        }
243
244        debug!("Listed {} secrets in scope: {}", results.len(), scope);
245        Ok(results)
246    }
247
248    async fn exists(&self, scope: &str, name: &str) -> Result<bool> {
249        let storage_key = Self::make_key(scope, name);
250
251        let row = sqlx::query("SELECT 1 FROM secrets WHERE storage_key = ?")
252            .bind(&storage_key)
253            .fetch_optional(&self.pool)
254            .await
255            .map_err(|e| SecretsError::Storage(format!("Failed to check existence: {e}")))?;
256
257        Ok(row.is_some())
258    }
259}
260
261#[async_trait]
262impl SecretsStore for PersistentSecretsStore {
263    async fn set_secret(&self, scope: &str, name: &str, value: &Secret) -> Result<()> {
264        let storage_key = Self::make_key(scope, name);
265
266        // Encrypt the secret value
267        let encrypted = self.key.encrypt(value.expose().as_bytes())?;
268
269        let now = Self::now_iso8601();
270
271        // Check if secret exists to determine version
272        let existing = sqlx::query("SELECT version, created_at FROM secrets WHERE storage_key = ?")
273            .bind(&storage_key)
274            .fetch_optional(&self.pool)
275            .await
276            .map_err(|e| SecretsError::Storage(format!("Failed to check existing: {e}")))?;
277
278        if let Some(row) = existing {
279            // Update existing secret
280            let version: i64 = row.try_get("version").unwrap_or(1);
281            let new_version = version + 1;
282
283            sqlx::query(
284                "UPDATE secrets SET encrypted_value = ?, version = ?, updated_at = ? WHERE storage_key = ?",
285            )
286            .bind(&encrypted)
287            .bind(new_version)
288            .bind(&now)
289            .bind(&storage_key)
290            .execute(&self.pool)
291            .await
292            .map_err(|e| SecretsError::Storage(format!("Failed to update secret: {e}")))?;
293
294            debug!("Updated secret: {} (version {})", storage_key, new_version);
295        } else {
296            // Insert new secret
297            sqlx::query(
298                "INSERT INTO secrets (storage_key, encrypted_value, name, version, created_at, updated_at) VALUES (?, ?, ?, 1, ?, ?)",
299            )
300            .bind(&storage_key)
301            .bind(&encrypted)
302            .bind(name)
303            .bind(&now)
304            .bind(&now)
305            .execute(&self.pool)
306            .await
307            .map_err(|e| SecretsError::Storage(format!("Failed to insert secret: {e}")))?;
308
309            debug!("Stored secret: {} (version 1)", storage_key);
310        }
311
312        Ok(())
313    }
314
315    async fn delete_secret(&self, scope: &str, name: &str) -> Result<()> {
316        let storage_key = Self::make_key(scope, name);
317
318        let result = sqlx::query("DELETE FROM secrets WHERE storage_key = ?")
319            .bind(&storage_key)
320            .execute(&self.pool)
321            .await
322            .map_err(|e| SecretsError::Storage(format!("Failed to delete secret: {e}")))?;
323
324        if result.rows_affected() == 0 {
325            return Err(SecretsError::NotFound {
326                name: name.to_string(),
327            });
328        }
329
330        debug!("Deleted secret: {}", storage_key);
331        Ok(())
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    async fn create_test_store() -> (PersistentSecretsStore, tempfile::TempDir) {
340        let temp_dir = tempfile::tempdir().unwrap();
341        let db_path = temp_dir.path().join("test_secrets.sqlite");
342        let key = EncryptionKey::generate();
343        let store = PersistentSecretsStore::open(&db_path, key).await.unwrap();
344        (store, temp_dir)
345    }
346
347    #[tokio::test]
348    async fn test_set_and_get_secret() {
349        let (store, _temp) = create_test_store().await;
350
351        let secret = Secret::new("super-secret-value");
352        store
353            .set_secret("deployment/myapp", "db-password", &secret)
354            .await
355            .unwrap();
356
357        let retrieved = store
358            .get_secret("deployment/myapp", "db-password")
359            .await
360            .unwrap();
361        assert_eq!(retrieved.expose(), "super-secret-value");
362    }
363
364    #[tokio::test]
365    async fn test_get_nonexistent_secret() {
366        let (store, _temp) = create_test_store().await;
367
368        let result = store.get_secret("deployment/myapp", "nonexistent").await;
369        assert!(matches!(result, Err(SecretsError::NotFound { .. })));
370    }
371
372    #[tokio::test]
373    async fn test_exists() {
374        let (store, _temp) = create_test_store().await;
375
376        assert!(!store.exists("scope", "name").await.unwrap());
377
378        let secret = Secret::new("value");
379        store.set_secret("scope", "name", &secret).await.unwrap();
380
381        assert!(store.exists("scope", "name").await.unwrap());
382    }
383
384    #[tokio::test]
385    async fn test_delete_secret() {
386        let (store, _temp) = create_test_store().await;
387
388        let secret = Secret::new("to-be-deleted");
389        store
390            .set_secret("scope", "deleteme", &secret)
391            .await
392            .unwrap();
393        assert!(store.exists("scope", "deleteme").await.unwrap());
394
395        store.delete_secret("scope", "deleteme").await.unwrap();
396        assert!(!store.exists("scope", "deleteme").await.unwrap());
397    }
398
399    #[tokio::test]
400    async fn test_delete_nonexistent() {
401        let (store, _temp) = create_test_store().await;
402
403        let result = store.delete_secret("scope", "nonexistent").await;
404        assert!(matches!(result, Err(SecretsError::NotFound { .. })));
405    }
406
407    #[tokio::test]
408    async fn test_list_secrets() {
409        let (store, _temp) = create_test_store().await;
410
411        // Add secrets to different scopes
412        store
413            .set_secret("scope1", "secret-a", &Secret::new("a"))
414            .await
415            .unwrap();
416        store
417            .set_secret("scope1", "secret-b", &Secret::new("b"))
418            .await
419            .unwrap();
420        store
421            .set_secret("scope2", "secret-c", &Secret::new("c"))
422            .await
423            .unwrap();
424
425        // List scope1 - should only see 2 secrets
426        let list = store.list_secrets("scope1").await.unwrap();
427        assert_eq!(list.len(), 2);
428        assert_eq!(list[0].name, "secret-a");
429        assert_eq!(list[1].name, "secret-b");
430
431        // List scope2 - should only see 1 secret
432        let list = store.list_secrets("scope2").await.unwrap();
433        assert_eq!(list.len(), 1);
434        assert_eq!(list[0].name, "secret-c");
435    }
436
437    #[tokio::test]
438    async fn test_get_secrets_batch() {
439        let (store, _temp) = create_test_store().await;
440
441        store
442            .set_secret("scope", "a", &Secret::new("value-a"))
443            .await
444            .unwrap();
445        store
446            .set_secret("scope", "b", &Secret::new("value-b"))
447            .await
448            .unwrap();
449        store
450            .set_secret("scope", "c", &Secret::new("value-c"))
451            .await
452            .unwrap();
453
454        let results = store
455            .get_secrets("scope", &["a", "c", "nonexistent"])
456            .await
457            .unwrap();
458        assert_eq!(results.len(), 2);
459
460        assert_eq!(results.get("a").unwrap().expose(), "value-a");
461        assert_eq!(results.get("c").unwrap().expose(), "value-c");
462        assert!(!results.contains_key("nonexistent"));
463    }
464
465    #[tokio::test]
466    async fn test_update_increments_version() {
467        let (store, _temp) = create_test_store().await;
468
469        store
470            .set_secret("scope", "versioned", &Secret::new("v1"))
471            .await
472            .unwrap();
473
474        let list = store.list_secrets("scope").await.unwrap();
475        assert_eq!(list[0].version, 1);
476
477        store
478            .set_secret("scope", "versioned", &Secret::new("v2"))
479            .await
480            .unwrap();
481
482        let list = store.list_secrets("scope").await.unwrap();
483        assert_eq!(list[0].version, 2);
484    }
485
486    #[tokio::test]
487    async fn test_persistence() {
488        let temp_dir = tempfile::tempdir().unwrap();
489        let db_path = temp_dir.path().join("persist_test.sqlite");
490
491        // Use fixed key for persistence test
492        let key_bytes = [42u8; 32];
493        let key = EncryptionKey::from_bytes(&key_bytes).unwrap();
494
495        // Write data
496        {
497            let store = PersistentSecretsStore::open(&db_path, key.clone())
498                .await
499                .unwrap();
500            store
501                .set_secret("scope", "persistent", &Secret::new("persistent-value"))
502                .await
503                .unwrap();
504        }
505
506        // Reopen and verify
507        {
508            let store = PersistentSecretsStore::open(&db_path, key).await.unwrap();
509            let secret = store.get_secret("scope", "persistent").await.unwrap();
510            assert_eq!(secret.expose(), "persistent-value");
511        }
512    }
513
514    #[tokio::test]
515    async fn test_wrong_key_fails_decryption() {
516        let temp_dir = tempfile::tempdir().unwrap();
517        let db_path = temp_dir.path().join("wrong_key_test.sqlite");
518
519        // Write with one key
520        let key1 = EncryptionKey::generate();
521        {
522            let store = PersistentSecretsStore::open(&db_path, key1).await.unwrap();
523            store
524                .set_secret("scope", "secret", &Secret::new("value"))
525                .await
526                .unwrap();
527        }
528
529        // Try to read with different key
530        let key2 = EncryptionKey::generate();
531        {
532            let store = PersistentSecretsStore::open(&db_path, key2).await.unwrap();
533            let result = store.get_secret("scope", "secret").await;
534            assert!(result.is_err()); // Should fail decryption
535        }
536    }
537
538    #[tokio::test]
539    async fn test_open_with_directory() {
540        let temp_dir = tempfile::tempdir().unwrap();
541        let key = EncryptionKey::generate();
542
543        // Pass directory path instead of file path
544        let store = PersistentSecretsStore::open(temp_dir.path(), key)
545            .await
546            .unwrap();
547
548        store
549            .set_secret("scope", "test", &Secret::new("value"))
550            .await
551            .unwrap();
552
553        // Verify database file was created
554        let expected_path = temp_dir.path().join(DEFAULT_DB_FILENAME);
555        assert!(expected_path.exists());
556    }
557
558    #[test]
559    fn test_make_key() {
560        assert_eq!(
561            PersistentSecretsStore::make_key("scope", "name"),
562            "scope:name"
563        );
564        assert_eq!(
565            PersistentSecretsStore::make_key("deployment/app", "secret"),
566            "deployment/app:secret"
567        );
568    }
569
570    #[tokio::test]
571    async fn test_empty_secret() {
572        let (store, _temp) = create_test_store().await;
573
574        let secret = Secret::new("");
575        store.set_secret("scope", "empty", &secret).await.unwrap();
576
577        let retrieved = store.get_secret("scope", "empty").await.unwrap();
578        assert_eq!(retrieved.expose(), "");
579    }
580
581    #[tokio::test]
582    async fn test_unicode_secret() {
583        let (store, _temp) = create_test_store().await;
584
585        let secret = Secret::new("hello world");
586        store.set_secret("scope", "unicode", &secret).await.unwrap();
587
588        let retrieved = store.get_secret("scope", "unicode").await.unwrap();
589        assert_eq!(retrieved.expose(), "hello world");
590    }
591
592    #[tokio::test]
593    async fn test_large_secret() {
594        let (store, _temp) = create_test_store().await;
595
596        // 1MB secret
597        let large_value: String = "x".repeat(1024 * 1024);
598        let secret = Secret::new(&large_value);
599        store.set_secret("scope", "large", &secret).await.unwrap();
600
601        let retrieved = store.get_secret("scope", "large").await.unwrap();
602        assert_eq!(retrieved.expose().len(), 1024 * 1024);
603    }
604}