Skip to main content

secrets_provider_dev/
lib.rs

1use base64::Engine;
2use base64::engine::general_purpose::STANDARD_NO_PAD;
3use fs2::FileExt;
4use greentic_secrets_spec::{
5    KeyProvider, Scope, SecretListItem, SecretRecord, SecretUri, SecretVersion, SecretsBackend,
6    SecretsError as Error, SecretsResult as Result, VersionedSecret,
7};
8use parking_lot::RwLock;
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11use std::collections::BTreeMap;
12use std::fs::OpenOptions;
13use std::io::{BufRead, BufReader, BufWriter, Seek, SeekFrom, Write};
14use std::path::PathBuf;
15use std::sync::Arc;
16
17const DEFAULT_PERSIST_PATH: &str = ".dev.secrets.env";
18const PERSIST_ENV: &str = "GREENTIC_DEV_SECRETS_PATH";
19const ENV_KEY: &str = "SECRETS_BACKEND_STATE";
20const MASTER_KEY_ENV: &str = "GREENTIC_DEV_MASTER_KEY";
21
22/// Simple development key provider that uses deterministic material to wrap DEKs.
23#[derive(Clone, Default)]
24pub struct DevKeyProvider {
25    master_key: [u8; 32],
26}
27
28impl DevKeyProvider {
29    /// Construct the provider from environment configuration.
30    pub fn from_env() -> Self {
31        let material = std::env::var(MASTER_KEY_ENV).unwrap_or_default();
32        Self::from_material(material.as_bytes())
33    }
34
35    /// Construct the provider by hashing arbitrary input into a fixed-size key.
36    pub fn from_material(input: &[u8]) -> Self {
37        let mut hasher = Sha256::new();
38        hasher.update(input);
39        let digest = hasher.finalize();
40        let mut master_key = [0u8; 32];
41        master_key.copy_from_slice(&digest);
42        Self { master_key }
43    }
44}
45
46impl KeyProvider for DevKeyProvider {
47    fn wrap_dek(&self, _scope: &Scope, dek: &[u8]) -> Result<Vec<u8>> {
48        Ok(xor_with_key(dek, &self.master_key))
49    }
50
51    fn unwrap_dek(&self, _scope: &Scope, wrapped: &[u8]) -> Result<Vec<u8>> {
52        Ok(xor_with_key(wrapped, &self.master_key))
53    }
54}
55
56fn xor_with_key(input: &[u8], key: &[u8; 32]) -> Vec<u8> {
57    input
58        .iter()
59        .enumerate()
60        .map(|(idx, byte)| byte ^ key[idx % key.len()])
61        .collect()
62}
63
64#[derive(Clone, Default)]
65struct State {
66    entries: BTreeMap<String, Vec<VersionEntry>>,
67}
68
69#[derive(Clone, Serialize, Deserialize)]
70struct VersionEntry {
71    version: u64,
72    deleted: bool,
73    record: Option<SecretRecord>,
74}
75
76impl VersionEntry {
77    fn live(version: u64, record: SecretRecord) -> Self {
78        Self {
79            version,
80            deleted: false,
81            record: Some(record),
82        }
83    }
84
85    fn tombstone(version: u64) -> Self {
86        Self {
87            version,
88            deleted: true,
89            record: None,
90        }
91    }
92
93    fn as_version(&self) -> SecretVersion {
94        SecretVersion {
95            version: self.version,
96            deleted: self.deleted,
97        }
98    }
99
100    fn as_versioned(&self) -> VersionedSecret {
101        VersionedSecret {
102            version: self.version,
103            deleted: self.deleted,
104            record: self.record.clone(),
105        }
106    }
107}
108
109#[derive(Clone)]
110struct Persistence {
111    path: PathBuf,
112}
113
114impl Persistence {
115    fn load(path: PathBuf) -> Result<(State, Self)> {
116        let file = OpenOptions::new()
117            .read(true)
118            .write(true)
119            .create(true)
120            .truncate(false)
121            .open(&path)
122            .map_err(|err| Error::Storage(err.to_string()))?;
123
124        file.lock_exclusive()
125            .map_err(|err| Error::Storage(err.to_string()))?;
126
127        let result = (|| -> Result<State> {
128            let reader = BufReader::new(&file);
129            for line in reader.lines() {
130                let line = line.map_err(|err| Error::Storage(err.to_string()))?;
131                if line.trim().is_empty() || line.starts_with('#') {
132                    continue;
133                }
134
135                if let Some((key, value)) = line.split_once('=')
136                    && key.trim() == ENV_KEY
137                {
138                    let decoded = STANDARD_NO_PAD
139                        .decode(value.trim())
140                        .map_err(|err| Error::Storage(err.to_string()))?;
141                    let persisted: PersistedState = serde_json::from_slice(&decoded)
142                        .map_err(|err| Error::Storage(err.to_string()))?;
143                    return Ok(persisted.into_state());
144                }
145            }
146            Ok(State::default())
147        })();
148
149        let _ = fs2::FileExt::unlock(&file);
150        result.map(|state| (state, Self { path }))
151    }
152
153    fn persist(&self, state: &State) -> Result<()> {
154        let mut file = OpenOptions::new()
155            .read(true)
156            .write(true)
157            .create(true)
158            .truncate(false)
159            .open(&self.path)
160            .map_err(|err| Error::Storage(err.to_string()))?;
161
162        file.lock_exclusive()
163            .map_err(|err| Error::Storage(err.to_string()))?;
164
165        let result = (|| -> Result<()> {
166            file.set_len(0)
167                .map_err(|err| Error::Storage(err.to_string()))?;
168            file.seek(SeekFrom::Start(0))
169                .map_err(|err| Error::Storage(err.to_string()))?;
170
171            let persisted = PersistedState::from_state(state);
172            let json =
173                serde_json::to_vec(&persisted).map_err(|err| Error::Storage(err.to_string()))?;
174            let encoded = STANDARD_NO_PAD.encode(json);
175
176            let mut writer = BufWriter::new(&file);
177            writer
178                .write_all(format!("{ENV_KEY}={encoded}\n").as_bytes())
179                .map_err(|err| Error::Storage(err.to_string()))?;
180            writer
181                .flush()
182                .map_err(|err| Error::Storage(err.to_string()))?;
183            Ok(())
184        })();
185
186        let _ = fs2::FileExt::unlock(&file);
187        result
188    }
189}
190
191#[derive(Serialize, Deserialize)]
192struct PersistedState {
193    secrets: Vec<PersistedSecret>,
194}
195
196impl PersistedState {
197    fn from_state(state: &State) -> Self {
198        let secrets = state
199            .entries
200            .iter()
201            .map(|(key, versions)| PersistedSecret {
202                key: key.clone(),
203                versions: versions.clone(),
204            })
205            .collect();
206        Self { secrets }
207    }
208
209    fn into_state(self) -> State {
210        let mut entries = BTreeMap::new();
211        for secret in self.secrets {
212            entries.insert(secret.key, secret.versions);
213        }
214        State { entries }
215    }
216}
217
218#[derive(Serialize, Deserialize)]
219struct PersistedSecret {
220    key: String,
221    versions: Vec<VersionEntry>,
222}
223
224/// Development backend that stores ciphertexts in-memory with optional .env persistence.
225#[derive(Clone)]
226pub struct DevBackend {
227    state: Arc<RwLock<State>>,
228    persistence: Option<Persistence>,
229}
230
231impl Default for DevBackend {
232    fn default() -> Self {
233        Self::new()
234    }
235}
236
237impl DevBackend {
238    /// Construct a purely in-memory backend.
239    pub fn new() -> Self {
240        Self {
241            state: Arc::new(RwLock::new(State::default())),
242            persistence: None,
243        }
244    }
245
246    /// Construct a backend that persists state to the specified .env file.
247    pub fn with_persistence<P: Into<PathBuf>>(path: P) -> Result<Self> {
248        let path = path.into();
249        let (state, persistence) = Persistence::load(path)?;
250        Ok(Self {
251            state: Arc::new(RwLock::new(state)),
252            persistence: Some(persistence),
253        })
254    }
255
256    /// Construct from environment configuration. If the configured file does not exist,
257    /// the backend falls back to in-memory storage.
258    pub fn from_env() -> Result<Self> {
259        if let Ok(path) = std::env::var(PERSIST_ENV) {
260            return Self::with_persistence(PathBuf::from(path));
261        }
262
263        let default_path = PathBuf::from(DEFAULT_PERSIST_PATH);
264        if default_path.exists() {
265            Self::with_persistence(default_path)
266        } else {
267            Ok(Self::new())
268        }
269    }
270
271    fn persist_if_needed(&self, state: State) -> Result<()> {
272        if let Some(persistence) = &self.persistence {
273            persistence.persist(&state)?;
274        }
275        Ok(())
276    }
277}
278
279impl SecretsBackend for DevBackend {
280    fn put(&self, record: SecretRecord) -> Result<SecretVersion> {
281        let key = record.meta.uri.to_string();
282        let mut state_guard = self.state.write();
283        let versions = state_guard.entries.entry(key).or_default();
284        let next_version = versions.last().map(|v| v.version + 1).unwrap_or(1);
285
286        versions.push(VersionEntry::live(next_version, record));
287        let snapshot = if self.persistence.is_some() {
288            Some(state_guard.clone())
289        } else {
290            None
291        };
292        drop(state_guard);
293
294        if let Some(state) = snapshot {
295            self.persist_if_needed(state)?;
296        }
297
298        Ok(SecretVersion {
299            version: next_version,
300            deleted: false,
301        })
302    }
303
304    fn get(&self, uri: &SecretUri, version: Option<u64>) -> Result<Option<VersionedSecret>> {
305        let key = uri.to_string();
306        let state = self.state.read();
307        let versions = match state.entries.get(&key) {
308            Some(versions) => versions,
309            None => return Ok(None),
310        };
311
312        if let Some(target) = version {
313            let entry = versions.iter().find(|entry| entry.version == target);
314            return Ok(entry.cloned().map(|entry| entry.as_versioned()));
315        }
316
317        if matches!(versions.last(), Some(entry) if entry.deleted) {
318            return Ok(None);
319        }
320
321        let latest = versions.iter().rev().find(|entry| !entry.deleted).cloned();
322        Ok(latest.map(|entry| entry.as_versioned()))
323    }
324
325    fn list(
326        &self,
327        scope: &Scope,
328        category_prefix: Option<&str>,
329        name_prefix: Option<&str>,
330    ) -> Result<Vec<SecretListItem>> {
331        let state = self.state.read();
332        let mut items = Vec::new();
333
334        for versions in state.entries.values() {
335            if matches!(versions.last(), Some(entry) if entry.deleted) {
336                continue;
337            }
338
339            let latest = match versions.iter().rev().find(|entry| !entry.deleted) {
340                Some(entry) => entry,
341                None => continue,
342            };
343
344            let record = match &latest.record {
345                Some(record) => record,
346                None => continue,
347            };
348
349            let secret_scope = record.meta.scope();
350            if scope.env() != secret_scope.env() || scope.tenant() != secret_scope.tenant() {
351                continue;
352            }
353            if scope.team() != secret_scope.team() {
354                continue;
355            }
356
357            if let Some(prefix) = category_prefix
358                && !record.meta.uri.category().starts_with(prefix)
359            {
360                continue;
361            }
362
363            if let Some(prefix) = name_prefix
364                && !record.meta.uri.name().starts_with(prefix)
365            {
366                continue;
367            }
368
369            items.push(SecretListItem::from_meta(
370                &record.meta,
371                Some(latest.version.to_string()),
372            ));
373        }
374
375        items.sort_by_key(|a| a.uri.to_string());
376        Ok(items)
377    }
378
379    fn delete(&self, uri: &SecretUri) -> Result<SecretVersion> {
380        let key = uri.to_string();
381        let mut state_guard = self.state.write();
382        let versions = match state_guard.entries.get_mut(&key) {
383            Some(versions) => versions,
384            None => {
385                return Err(Error::NotFound {
386                    entity: uri.to_string(),
387                });
388            }
389        };
390
391        let has_live = versions.iter().any(|entry| !entry.deleted);
392        if !has_live {
393            return Err(Error::NotFound {
394                entity: uri.to_string(),
395            });
396        }
397
398        let next_version = versions.last().map(|v| v.version + 1).unwrap_or(1);
399        versions.push(VersionEntry::tombstone(next_version));
400        let snapshot = if self.persistence.is_some() {
401            Some(state_guard.clone())
402        } else {
403            None
404        };
405        drop(state_guard);
406
407        if let Some(state) = snapshot {
408            self.persist_if_needed(state)?;
409        }
410
411        Ok(SecretVersion {
412            version: next_version,
413            deleted: true,
414        })
415    }
416
417    fn versions(&self, uri: &SecretUri) -> Result<Vec<SecretVersion>> {
418        let key = uri.to_string();
419        let state = self.state.read();
420        let versions = match state.entries.get(&key) {
421            Some(versions) => versions,
422            None => return Ok(Vec::new()),
423        };
424
425        Ok(versions.iter().map(|entry| entry.as_version()).collect())
426    }
427
428    fn exists(&self, uri: &SecretUri) -> Result<bool> {
429        let key = uri.to_string();
430        let state = self.state.read();
431        let versions = match state.entries.get(&key) {
432            Some(versions) => versions,
433            None => return Ok(false),
434        };
435
436        Ok(matches!(versions.last(), Some(entry) if !entry.deleted))
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443    use greentic_secrets_spec::{
444        ContentType, EncryptionAlgorithm, Envelope, SecretMeta, Visibility,
445    };
446    use serde_json::json;
447    use std::fs;
448    use std::process::Command;
449    use std::thread;
450    use std::time::{Duration, SystemTime, UNIX_EPOCH};
451
452    const PERSIST_CHILD_ENV: &str = "GREENTIC_DEV_PERSIST_CHILD";
453
454    fn sample_scope() -> Scope {
455        Scope::new("dev", "acme", Some("payments".into())).unwrap()
456    }
457
458    fn sample_uri(scope: &Scope, category: &str, name: &str) -> SecretUri {
459        SecretUri::new(scope.clone(), category, name).unwrap()
460    }
461
462    fn record(uri: &SecretUri, content_type: ContentType, payload: Vec<u8>) -> SecretRecord {
463        let meta = SecretMeta::new(uri.clone(), Visibility::Team, content_type);
464        let envelope = Envelope {
465            algorithm: EncryptionAlgorithm::Aes256Gcm,
466            nonce: Vec::new(),
467            hkdf_salt: Vec::new(),
468            wrapped_dek: Vec::new(),
469        };
470        SecretRecord::new(meta, payload, envelope)
471    }
472
473    #[test]
474    fn backend_put_get_latest_and_versioned() {
475        let backend = DevBackend::new();
476        let scope = sample_scope();
477        let uri = sample_uri(&scope, "kv", "db-password");
478
479        let payload_v1 = serde_json::to_vec(&json!({"password": "s3cr3t"})).unwrap();
480        let v1 = backend
481            .put(record(&uri, ContentType::Json, payload_v1.clone()))
482            .unwrap();
483        assert_eq!(v1.version, 1);
484
485        let latest = backend.get(&uri, None).unwrap().expect("latest record");
486        assert_eq!(latest.version, 1);
487        let stored = latest.record.expect("record payload");
488        assert_eq!(stored.value, payload_v1);
489        assert_eq!(stored.meta.content_type, ContentType::Json);
490
491        let payload_v2 = serde_json::to_vec(&json!({"password": "n3w"})).unwrap();
492        let v2 = backend
493            .put(record(&uri, ContentType::Json, payload_v2.clone()))
494            .unwrap();
495        assert_eq!(v2.version, 2);
496
497        let latest = backend.get(&uri, None).unwrap().expect("latest record");
498        assert_eq!(latest.version, 2);
499        let stored = latest.record.expect("record payload");
500        assert_eq!(stored.value, payload_v2);
501
502        let version_one = backend.get(&uri, Some(1)).unwrap().expect("v1 record");
503        assert_eq!(version_one.version, 1);
504        let stored = version_one.record.expect("record payload");
505        assert_eq!(
506            stored.value,
507            serde_json::to_vec(&json!({"password": "s3cr3t"})).unwrap()
508        );
509    }
510
511    #[test]
512    fn list_with_prefix() {
513        let backend = DevBackend::new();
514        let scope = sample_scope();
515        let uri_api = sample_uri(&scope, "kv", "api-token");
516        let uri_db = sample_uri(&scope, "kv", "db-password");
517        let uri_cfg = sample_uri(&scope, "config", "feature-flags");
518
519        backend
520            .put(record(&uri_api, ContentType::Opaque, b"api".to_vec()))
521            .unwrap();
522        backend
523            .put(record(&uri_db, ContentType::Text, b"db".to_vec()))
524            .unwrap();
525        backend
526            .put(record(
527                &uri_cfg,
528                ContentType::Json,
529                serde_json::to_vec(&json!({"feature": true})).unwrap(),
530            ))
531            .unwrap();
532
533        let kv = backend.list(&scope, Some("kv"), None).unwrap();
534        assert_eq!(kv.len(), 2);
535
536        let api_only = backend.list(&scope, Some("kv"), Some("api")).unwrap();
537        assert_eq!(api_only.len(), 1);
538        assert!(api_only[0].uri.to_string().contains("api-token"));
539    }
540
541    #[test]
542    fn delete_and_restore() {
543        let backend = DevBackend::new();
544        let scope = sample_scope();
545        let uri = sample_uri(&scope, "kv", "session-key");
546
547        backend
548            .put(record(&uri, ContentType::Binary, vec![0x01, 0x02, 0x03]))
549            .unwrap();
550
551        assert!(backend.exists(&uri).unwrap());
552        backend.delete(&uri).unwrap();
553        assert!(!backend.exists(&uri).unwrap());
554        assert!(backend.get(&uri, None).unwrap().is_none());
555
556        backend
557            .put(record(&uri, ContentType::Binary, vec![0xAA, 0xBB]))
558            .unwrap();
559
560        let latest = backend.get(&uri, None).unwrap().expect("restored");
561        let record = latest.record.expect("record payload");
562        assert_eq!(record.value, vec![0xAA, 0xBB]);
563        assert!(backend.exists(&uri).unwrap());
564    }
565
566    #[test]
567    fn content_types_round_trip() {
568        let backend = DevBackend::new();
569        let scope = sample_scope();
570
571        let text_uri = sample_uri(&scope, "kv", "text");
572        let bin_uri = sample_uri(&scope, "kv", "bin");
573
574        backend
575            .put(record(
576                &text_uri,
577                ContentType::Text,
578                b"hello world".to_vec(),
579            ))
580            .unwrap();
581        backend
582            .put(record(&bin_uri, ContentType::Binary, vec![0, 1, 2, 3]))
583            .unwrap();
584
585        let text_record = backend
586            .get(&text_uri, None)
587            .unwrap()
588            .unwrap()
589            .record
590            .unwrap();
591        assert_eq!(text_record.meta.content_type, ContentType::Text);
592        assert_eq!(text_record.value, b"hello world".to_vec());
593
594        let bin_record = backend
595            .get(&bin_uri, None)
596            .unwrap()
597            .unwrap()
598            .record
599            .unwrap();
600        assert_eq!(bin_record.meta.content_type, ContentType::Binary);
601        assert_eq!(bin_record.value, vec![0, 1, 2, 3]);
602    }
603
604    #[test]
605    fn key_provider_wrap_unwrap() {
606        let provider = DevKeyProvider::from_material(b"material");
607        let scope = sample_scope();
608        let dek = vec![1, 2, 3, 4, 5];
609        let wrapped = provider.wrap_dek(&scope, &dek).unwrap();
610        assert_eq!(wrapped.len(), dek.len());
611        assert_ne!(wrapped, dek);
612        let unwrapped = provider.unwrap_dek(&scope, &wrapped).unwrap();
613        assert_eq!(unwrapped, dek);
614    }
615
616    #[test]
617    fn persistence_does_not_truncate_before_lock() {
618        if let Some(path) = std::env::var_os(PERSIST_CHILD_ENV) {
619            Persistence {
620                path: PathBuf::from(path),
621            }
622            .persist(&State::default())
623            .unwrap();
624            return;
625        }
626
627        let temp = std::env::temp_dir().join(format!(
628            "greentic-dev-persist-test-{}-{}",
629            std::process::id(),
630            SystemTime::now()
631                .duration_since(UNIX_EPOCH)
632                .unwrap()
633                .as_nanos()
634        ));
635        fs::create_dir(&temp).unwrap();
636        let path = temp.join(".dev.secrets.env");
637        let original = format!("{ENV_KEY}=eyJzZWNyZXRzIjpbXX0\n");
638        fs::write(&path, &original).unwrap();
639
640        let locked = OpenOptions::new()
641            .read(true)
642            .write(true)
643            .open(&path)
644            .unwrap();
645        locked.lock_exclusive().unwrap();
646
647        let mut child = Command::new(std::env::current_exe().unwrap())
648            .arg("persistence_does_not_truncate_before_lock")
649            .arg("--exact")
650            .env(PERSIST_CHILD_ENV, &path)
651            .spawn()
652            .unwrap();
653
654        thread::sleep(Duration::from_millis(250));
655        assert_eq!(fs::read_to_string(&path).unwrap(), original);
656
657        fs2::FileExt::unlock(&locked).unwrap();
658        let status = child.wait().unwrap();
659        assert!(status.success());
660
661        DevBackend::with_persistence(&path).unwrap();
662        fs::remove_dir_all(&temp).unwrap();
663    }
664}