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, 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 file = OpenOptions::new()
155            .read(true)
156            .write(true)
157            .create(true)
158            .truncate(true)
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 persisted = PersistedState::from_state(state);
166        let json = serde_json::to_vec(&persisted).map_err(|err| Error::Storage(err.to_string()))?;
167        let encoded = STANDARD_NO_PAD.encode(json);
168
169        let mut writer = BufWriter::new(&file);
170        writer
171            .write_all(format!("{ENV_KEY}={encoded}\n").as_bytes())
172            .map_err(|err| Error::Storage(err.to_string()))?;
173        writer
174            .flush()
175            .map_err(|err| Error::Storage(err.to_string()))?;
176
177        let _ = fs2::FileExt::unlock(&file);
178        Ok(())
179    }
180}
181
182#[derive(Serialize, Deserialize)]
183struct PersistedState {
184    secrets: Vec<PersistedSecret>,
185}
186
187impl PersistedState {
188    fn from_state(state: &State) -> Self {
189        let secrets = state
190            .entries
191            .iter()
192            .map(|(key, versions)| PersistedSecret {
193                key: key.clone(),
194                versions: versions.clone(),
195            })
196            .collect();
197        Self { secrets }
198    }
199
200    fn into_state(self) -> State {
201        let mut entries = BTreeMap::new();
202        for secret in self.secrets {
203            entries.insert(secret.key, secret.versions);
204        }
205        State { entries }
206    }
207}
208
209#[derive(Serialize, Deserialize)]
210struct PersistedSecret {
211    key: String,
212    versions: Vec<VersionEntry>,
213}
214
215/// Development backend that stores ciphertexts in-memory with optional .env persistence.
216#[derive(Clone)]
217pub struct DevBackend {
218    state: Arc<RwLock<State>>,
219    persistence: Option<Persistence>,
220}
221
222impl Default for DevBackend {
223    fn default() -> Self {
224        Self::new()
225    }
226}
227
228impl DevBackend {
229    /// Construct a purely in-memory backend.
230    pub fn new() -> Self {
231        Self {
232            state: Arc::new(RwLock::new(State::default())),
233            persistence: None,
234        }
235    }
236
237    /// Construct a backend that persists state to the specified .env file.
238    pub fn with_persistence<P: Into<PathBuf>>(path: P) -> Result<Self> {
239        let path = path.into();
240        let (state, persistence) = Persistence::load(path)?;
241        Ok(Self {
242            state: Arc::new(RwLock::new(state)),
243            persistence: Some(persistence),
244        })
245    }
246
247    /// Construct from environment configuration. If the configured file does not exist,
248    /// the backend falls back to in-memory storage.
249    pub fn from_env() -> Result<Self> {
250        if let Ok(path) = std::env::var(PERSIST_ENV) {
251            return Self::with_persistence(PathBuf::from(path));
252        }
253
254        let default_path = PathBuf::from(DEFAULT_PERSIST_PATH);
255        if default_path.exists() {
256            Self::with_persistence(default_path)
257        } else {
258            Ok(Self::new())
259        }
260    }
261
262    fn persist_if_needed(&self, state: State) -> Result<()> {
263        if let Some(persistence) = &self.persistence {
264            persistence.persist(&state)?;
265        }
266        Ok(())
267    }
268}
269
270impl SecretsBackend for DevBackend {
271    fn put(&self, record: SecretRecord) -> Result<SecretVersion> {
272        let key = record.meta.uri.to_string();
273        let mut state_guard = self.state.write();
274        let versions = state_guard.entries.entry(key).or_default();
275        let next_version = versions.last().map(|v| v.version + 1).unwrap_or(1);
276
277        versions.push(VersionEntry::live(next_version, record));
278        let snapshot = if self.persistence.is_some() {
279            Some(state_guard.clone())
280        } else {
281            None
282        };
283        drop(state_guard);
284
285        if let Some(state) = snapshot {
286            self.persist_if_needed(state)?;
287        }
288
289        Ok(SecretVersion {
290            version: next_version,
291            deleted: false,
292        })
293    }
294
295    fn get(&self, uri: &SecretUri, version: Option<u64>) -> Result<Option<VersionedSecret>> {
296        let key = uri.to_string();
297        let state = self.state.read();
298        let versions = match state.entries.get(&key) {
299            Some(versions) => versions,
300            None => return Ok(None),
301        };
302
303        if let Some(target) = version {
304            let entry = versions.iter().find(|entry| entry.version == target);
305            return Ok(entry.cloned().map(|entry| entry.as_versioned()));
306        }
307
308        if matches!(versions.last(), Some(entry) if entry.deleted) {
309            return Ok(None);
310        }
311
312        let latest = versions.iter().rev().find(|entry| !entry.deleted).cloned();
313        Ok(latest.map(|entry| entry.as_versioned()))
314    }
315
316    fn list(
317        &self,
318        scope: &Scope,
319        category_prefix: Option<&str>,
320        name_prefix: Option<&str>,
321    ) -> Result<Vec<SecretListItem>> {
322        let state = self.state.read();
323        let mut items = Vec::new();
324
325        for versions in state.entries.values() {
326            if matches!(versions.last(), Some(entry) if entry.deleted) {
327                continue;
328            }
329
330            let latest = match versions.iter().rev().find(|entry| !entry.deleted) {
331                Some(entry) => entry,
332                None => continue,
333            };
334
335            let record = match &latest.record {
336                Some(record) => record,
337                None => continue,
338            };
339
340            let secret_scope = record.meta.scope();
341            if scope.env() != secret_scope.env() || scope.tenant() != secret_scope.tenant() {
342                continue;
343            }
344            if scope.team() != secret_scope.team() {
345                continue;
346            }
347
348            if let Some(prefix) = category_prefix
349                && !record.meta.uri.category().starts_with(prefix)
350            {
351                continue;
352            }
353
354            if let Some(prefix) = name_prefix
355                && !record.meta.uri.name().starts_with(prefix)
356            {
357                continue;
358            }
359
360            items.push(SecretListItem::from_meta(
361                &record.meta,
362                Some(latest.version.to_string()),
363            ));
364        }
365
366        items.sort_by(|a, b| a.uri.to_string().cmp(&b.uri.to_string()));
367        Ok(items)
368    }
369
370    fn delete(&self, uri: &SecretUri) -> Result<SecretVersion> {
371        let key = uri.to_string();
372        let mut state_guard = self.state.write();
373        let versions = match state_guard.entries.get_mut(&key) {
374            Some(versions) => versions,
375            None => {
376                return Err(Error::NotFound {
377                    entity: uri.to_string(),
378                });
379            }
380        };
381
382        let has_live = versions.iter().any(|entry| !entry.deleted);
383        if !has_live {
384            return Err(Error::NotFound {
385                entity: uri.to_string(),
386            });
387        }
388
389        let next_version = versions.last().map(|v| v.version + 1).unwrap_or(1);
390        versions.push(VersionEntry::tombstone(next_version));
391        let snapshot = if self.persistence.is_some() {
392            Some(state_guard.clone())
393        } else {
394            None
395        };
396        drop(state_guard);
397
398        if let Some(state) = snapshot {
399            self.persist_if_needed(state)?;
400        }
401
402        Ok(SecretVersion {
403            version: next_version,
404            deleted: true,
405        })
406    }
407
408    fn versions(&self, uri: &SecretUri) -> Result<Vec<SecretVersion>> {
409        let key = uri.to_string();
410        let state = self.state.read();
411        let versions = match state.entries.get(&key) {
412            Some(versions) => versions,
413            None => return Ok(Vec::new()),
414        };
415
416        Ok(versions.iter().map(|entry| entry.as_version()).collect())
417    }
418
419    fn exists(&self, uri: &SecretUri) -> Result<bool> {
420        let key = uri.to_string();
421        let state = self.state.read();
422        let versions = match state.entries.get(&key) {
423            Some(versions) => versions,
424            None => return Ok(false),
425        };
426
427        Ok(matches!(versions.last(), Some(entry) if !entry.deleted))
428    }
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434    use greentic_secrets_spec::{
435        ContentType, EncryptionAlgorithm, Envelope, SecretMeta, Visibility,
436    };
437    use serde_json::json;
438
439    fn sample_scope() -> Scope {
440        Scope::new("dev", "acme", Some("payments".into())).unwrap()
441    }
442
443    fn sample_uri(scope: &Scope, category: &str, name: &str) -> SecretUri {
444        SecretUri::new(scope.clone(), category, name).unwrap()
445    }
446
447    fn record(uri: &SecretUri, content_type: ContentType, payload: Vec<u8>) -> SecretRecord {
448        let meta = SecretMeta::new(uri.clone(), Visibility::Team, content_type);
449        let envelope = Envelope {
450            algorithm: EncryptionAlgorithm::Aes256Gcm,
451            nonce: Vec::new(),
452            hkdf_salt: Vec::new(),
453            wrapped_dek: Vec::new(),
454        };
455        SecretRecord::new(meta, payload, envelope)
456    }
457
458    #[test]
459    fn backend_put_get_latest_and_versioned() {
460        let backend = DevBackend::new();
461        let scope = sample_scope();
462        let uri = sample_uri(&scope, "kv", "db-password");
463
464        let payload_v1 = serde_json::to_vec(&json!({"password": "s3cr3t"})).unwrap();
465        let v1 = backend
466            .put(record(&uri, ContentType::Json, payload_v1.clone()))
467            .unwrap();
468        assert_eq!(v1.version, 1);
469
470        let latest = backend.get(&uri, None).unwrap().expect("latest record");
471        assert_eq!(latest.version, 1);
472        let stored = latest.record.expect("record payload");
473        assert_eq!(stored.value, payload_v1);
474        assert_eq!(stored.meta.content_type, ContentType::Json);
475
476        let payload_v2 = serde_json::to_vec(&json!({"password": "n3w"})).unwrap();
477        let v2 = backend
478            .put(record(&uri, ContentType::Json, payload_v2.clone()))
479            .unwrap();
480        assert_eq!(v2.version, 2);
481
482        let latest = backend.get(&uri, None).unwrap().expect("latest record");
483        assert_eq!(latest.version, 2);
484        let stored = latest.record.expect("record payload");
485        assert_eq!(stored.value, payload_v2);
486
487        let version_one = backend.get(&uri, Some(1)).unwrap().expect("v1 record");
488        assert_eq!(version_one.version, 1);
489        let stored = version_one.record.expect("record payload");
490        assert_eq!(
491            stored.value,
492            serde_json::to_vec(&json!({"password": "s3cr3t"})).unwrap()
493        );
494    }
495
496    #[test]
497    fn list_with_prefix() {
498        let backend = DevBackend::new();
499        let scope = sample_scope();
500        let uri_api = sample_uri(&scope, "kv", "api-token");
501        let uri_db = sample_uri(&scope, "kv", "db-password");
502        let uri_cfg = sample_uri(&scope, "config", "feature-flags");
503
504        backend
505            .put(record(&uri_api, ContentType::Opaque, b"api".to_vec()))
506            .unwrap();
507        backend
508            .put(record(&uri_db, ContentType::Text, b"db".to_vec()))
509            .unwrap();
510        backend
511            .put(record(
512                &uri_cfg,
513                ContentType::Json,
514                serde_json::to_vec(&json!({"feature": true})).unwrap(),
515            ))
516            .unwrap();
517
518        let kv = backend.list(&scope, Some("kv"), None).unwrap();
519        assert_eq!(kv.len(), 2);
520
521        let api_only = backend.list(&scope, Some("kv"), Some("api")).unwrap();
522        assert_eq!(api_only.len(), 1);
523        assert!(api_only[0].uri.to_string().contains("api-token"));
524    }
525
526    #[test]
527    fn delete_and_restore() {
528        let backend = DevBackend::new();
529        let scope = sample_scope();
530        let uri = sample_uri(&scope, "kv", "session-key");
531
532        backend
533            .put(record(&uri, ContentType::Binary, vec![0x01, 0x02, 0x03]))
534            .unwrap();
535
536        assert!(backend.exists(&uri).unwrap());
537        backend.delete(&uri).unwrap();
538        assert!(!backend.exists(&uri).unwrap());
539        assert!(backend.get(&uri, None).unwrap().is_none());
540
541        backend
542            .put(record(&uri, ContentType::Binary, vec![0xAA, 0xBB]))
543            .unwrap();
544
545        let latest = backend.get(&uri, None).unwrap().expect("restored");
546        let record = latest.record.expect("record payload");
547        assert_eq!(record.value, vec![0xAA, 0xBB]);
548        assert!(backend.exists(&uri).unwrap());
549    }
550
551    #[test]
552    fn content_types_round_trip() {
553        let backend = DevBackend::new();
554        let scope = sample_scope();
555
556        let text_uri = sample_uri(&scope, "kv", "text");
557        let bin_uri = sample_uri(&scope, "kv", "bin");
558
559        backend
560            .put(record(
561                &text_uri,
562                ContentType::Text,
563                b"hello world".to_vec(),
564            ))
565            .unwrap();
566        backend
567            .put(record(&bin_uri, ContentType::Binary, vec![0, 1, 2, 3]))
568            .unwrap();
569
570        let text_record = backend
571            .get(&text_uri, None)
572            .unwrap()
573            .unwrap()
574            .record
575            .unwrap();
576        assert_eq!(text_record.meta.content_type, ContentType::Text);
577        assert_eq!(text_record.value, b"hello world".to_vec());
578
579        let bin_record = backend
580            .get(&bin_uri, None)
581            .unwrap()
582            .unwrap()
583            .record
584            .unwrap();
585        assert_eq!(bin_record.meta.content_type, ContentType::Binary);
586        assert_eq!(bin_record.value, vec![0, 1, 2, 3]);
587    }
588
589    #[test]
590    fn key_provider_wrap_unwrap() {
591        let provider = DevKeyProvider::from_material(b"material");
592        let scope = sample_scope();
593        let dek = vec![1, 2, 3, 4, 5];
594        let wrapped = provider.wrap_dek(&scope, &dek).unwrap();
595        assert_eq!(wrapped.len(), dek.len());
596        assert_ne!(wrapped, dek);
597        let unwrapped = provider.unwrap_dek(&scope, &wrapped).unwrap();
598        assert_eq!(unwrapped, dek);
599    }
600}