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#[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 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#[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 pub fn new() -> Self {
231 Self {
232 state: Arc::new(RwLock::new(State::default())),
233 persistence: None,
234 }
235 }
236
237 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 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}