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#[derive(Clone, Default)]
24pub struct DevKeyProvider {
25 master_key: [u8; 32],
26}
27
28impl DevKeyProvider {
29 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 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#[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 pub fn new() -> Self {
240 Self {
241 state: Arc::new(RwLock::new(State::default())),
242 persistence: None,
243 }
244 }
245
246 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 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}