sherpack_core/
secrets.rs

1//! Secret generation and state management
2//!
3//! This module provides deterministic secret generation for Sherpack.
4//! Unlike Helm's `randAlphaNum` which generates different values on each render,
5//! Sherpack generates secrets once and stores them in cluster state.
6//!
7//! # Example
8//!
9//! ```jinja2
10//! {# In templates #}
11//! {{ generate_secret("db-password", 16) }}
12//! {{ generate_secret("api-key", 32, "urlsafe") }}
13//! ```
14//!
15//! # How it works
16//!
17//! 1. First `sherpack install`: generates random secrets, stores in Kubernetes Secret
18//! 2. Subsequent operations: reads existing values from state
19//! 3. Result: deterministic output, GitOps compatible
20
21use chrono::{DateTime, Utc};
22use rand::rngs::StdRng;
23use rand::{Rng, SeedableRng};
24use serde::{Deserialize, Serialize};
25use std::collections::HashMap;
26
27// =============================================================================
28// CHARSET
29// =============================================================================
30
31/// Character sets for secret generation
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
33#[serde(rename_all = "lowercase")]
34pub enum SecretCharset {
35    /// a-zA-Z0-9 (default)
36    #[default]
37    Alphanumeric,
38    /// a-zA-Z
39    Alpha,
40    /// 0-9
41    Numeric,
42    /// 0-9a-f
43    Hex,
44    /// a-zA-Z0-9+/
45    Base64,
46    /// a-zA-Z0-9-_ (URL safe)
47    UrlSafe,
48}
49
50impl SecretCharset {
51    /// Get the character set as bytes
52    pub const fn chars(&self) -> &'static [u8] {
53        match self {
54            Self::Alphanumeric => b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
55            Self::Alpha => b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
56            Self::Numeric => b"0123456789",
57            Self::Hex => b"0123456789abcdef",
58            Self::Base64 => b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",
59            Self::UrlSafe => b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_",
60        }
61    }
62
63    /// Parse charset from string
64    pub fn parse(s: &str) -> Option<Self> {
65        match s.to_lowercase().as_str() {
66            "alphanumeric" | "alnum" => Some(Self::Alphanumeric),
67            "alpha" => Some(Self::Alpha),
68            "numeric" | "num" | "digits" => Some(Self::Numeric),
69            "hex" => Some(Self::Hex),
70            "base64" => Some(Self::Base64),
71            "urlsafe" | "url" => Some(Self::UrlSafe),
72            _ => None,
73        }
74    }
75}
76
77// =============================================================================
78// SECRET ENTRY
79// =============================================================================
80
81/// A generated secret with metadata
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct SecretEntry {
84    /// The secret value
85    value: String,
86
87    /// When this secret was first generated
88    pub created_at: DateTime<Utc>,
89
90    /// When this secret was last rotated (if ever)
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub rotated_at: Option<DateTime<Utc>>,
93
94    /// The charset used to generate this secret
95    #[serde(default)]
96    pub charset: SecretCharset,
97
98    /// The length of the secret
99    pub length: usize,
100}
101
102impl SecretEntry {
103    /// Create a new secret entry
104    pub fn new(value: String, charset: SecretCharset, length: usize) -> Self {
105        Self {
106            value,
107            created_at: Utc::now(),
108            rotated_at: None,
109            charset,
110            length,
111        }
112    }
113
114    /// Get the secret value
115    pub fn value(&self) -> &str {
116        &self.value
117    }
118
119    /// Rotate the secret with a new value
120    pub fn rotate(&mut self, new_value: String) {
121        self.value = new_value;
122        self.rotated_at = Some(Utc::now());
123    }
124}
125
126// =============================================================================
127// SECRET STATE
128// =============================================================================
129
130/// State of all generated secrets for a release
131///
132/// This is persisted to Kubernetes and loaded before each operation.
133#[derive(Debug, Clone, Default, Serialize, Deserialize)]
134pub struct SecretState {
135    /// Schema version for future migrations
136    #[serde(default)]
137    pub version: u32,
138
139    /// Map of secret name to entry
140    #[serde(default)]
141    secrets: HashMap<String, SecretEntry>,
142
143    /// Whether any new secrets were generated (not persisted)
144    #[serde(skip)]
145    dirty: bool,
146}
147
148impl SecretState {
149    /// Current schema version
150    pub const CURRENT_VERSION: u32 = 1;
151
152    /// Create a new empty state
153    pub fn new() -> Self {
154        Self {
155            version: Self::CURRENT_VERSION,
156            secrets: HashMap::new(),
157            dirty: false,
158        }
159    }
160
161    /// Get a secret by name
162    pub fn get(&self, name: &str) -> Option<&SecretEntry> {
163        self.secrets.get(name)
164    }
165
166    /// Get a secret value by name
167    pub fn get_value(&self, name: &str) -> Option<&str> {
168        self.secrets.get(name).map(|e| e.value())
169    }
170
171    /// Insert a new secret
172    pub fn insert(&mut self, name: String, entry: SecretEntry) {
173        self.secrets.insert(name, entry);
174        self.dirty = true;
175    }
176
177    /// Check if any new secrets were generated
178    pub fn is_dirty(&self) -> bool {
179        self.dirty
180    }
181
182    /// Mark as clean (after persisting)
183    pub fn mark_clean(&mut self) {
184        self.dirty = false;
185    }
186
187    /// Get all secret names
188    pub fn names(&self) -> impl Iterator<Item = &str> {
189        self.secrets.keys().map(String::as_str)
190    }
191
192    /// Get all secrets
193    pub fn iter(&self) -> impl Iterator<Item = (&str, &SecretEntry)> {
194        self.secrets.iter().map(|(k, v)| (k.as_str(), v))
195    }
196
197    /// Number of secrets
198    pub fn len(&self) -> usize {
199        self.secrets.len()
200    }
201
202    /// Check if empty
203    pub fn is_empty(&self) -> bool {
204        self.secrets.is_empty()
205    }
206
207    /// Rotate a secret
208    pub fn rotate(&mut self, name: &str, new_value: String) -> bool {
209        if let Some(entry) = self.secrets.get_mut(name) {
210            entry.rotate(new_value);
211            self.dirty = true;
212            true
213        } else {
214            false
215        }
216    }
217}
218
219// Implement PartialEq manually to ignore dirty flag
220impl PartialEq<SecretState> for SecretState {
221    fn eq(&self, other: &SecretState) -> bool {
222        self.version == other.version && self.secrets == other.secrets
223    }
224}
225
226impl PartialEq for SecretEntry {
227    fn eq(&self, other: &Self) -> bool {
228        // Compare only value, charset, and length (not timestamps)
229        self.value == other.value && self.charset == other.charset && self.length == other.length
230    }
231}
232
233// =============================================================================
234// SECRET GENERATOR
235// =============================================================================
236
237/// Secret generator with state management
238///
239/// This is the main interface for generating secrets. It maintains state
240/// to ensure idempotent generation.
241#[derive(Debug)]
242pub struct SecretGenerator {
243    state: SecretState,
244    rng: StdRng,
245}
246
247impl SecretGenerator {
248    /// Create a new generator with empty state
249    pub fn new() -> Self {
250        Self {
251            state: SecretState::new(),
252            rng: StdRng::from_rng(&mut rand::rng()),
253        }
254    }
255
256    /// Create from existing state (loaded from Kubernetes)
257    pub fn with_state(state: SecretState) -> Self {
258        Self {
259            state,
260            rng: StdRng::from_rng(&mut rand::rng()),
261        }
262    }
263
264    /// Get or generate a secret with default charset
265    pub fn get_or_generate(&mut self, name: &str, length: usize) -> String {
266        self.get_or_generate_with_charset(name, length, SecretCharset::default())
267    }
268
269    /// Get or generate a secret with specific charset
270    pub fn get_or_generate_with_charset(
271        &mut self,
272        name: &str,
273        length: usize,
274        charset: SecretCharset,
275    ) -> String {
276        // Return existing secret if present
277        if let Some(entry) = self.state.get(name) {
278            return entry.value().to_string();
279        }
280
281        // Generate new secret
282        let value = self.generate_random(length, charset);
283        let entry = SecretEntry::new(value.clone(), charset, length);
284        self.state.insert(name.to_string(), entry);
285
286        value
287    }
288
289    /// Generate a random string (internal)
290    fn generate_random(&mut self, length: usize, charset: SecretCharset) -> String {
291        let chars = charset.chars();
292        (0..length)
293            .map(|_| {
294                let idx = self.rng.random_range(0..chars.len());
295                chars[idx] as char
296            })
297            .collect()
298    }
299
300    /// Get the current state
301    pub fn state(&self) -> &SecretState {
302        &self.state
303    }
304
305    /// Take ownership of the state (consumes the generator)
306    pub fn into_state(self) -> SecretState {
307        self.state
308    }
309
310    /// Check if any new secrets were generated
311    pub fn is_dirty(&self) -> bool {
312        self.state.is_dirty()
313    }
314
315    /// Rotate a secret with a new random value
316    pub fn rotate(&mut self, name: &str) -> Option<String> {
317        let entry = self.state.get(name)?;
318        let new_value = self.generate_random(entry.length, entry.charset);
319        self.state.rotate(name, new_value.clone());
320        Some(new_value)
321    }
322}
323
324impl Default for SecretGenerator {
325    fn default() -> Self {
326        Self::new()
327    }
328}
329
330// =============================================================================
331// TESTS
332// =============================================================================
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    #[test]
339    fn test_charset_chars() {
340        assert_eq!(SecretCharset::Numeric.chars(), b"0123456789");
341        assert_eq!(SecretCharset::Hex.chars().len(), 16);
342        assert_eq!(SecretCharset::Alphanumeric.chars().len(), 62);
343    }
344
345    #[test]
346    fn test_charset_parse() {
347        assert_eq!(SecretCharset::parse("hex"), Some(SecretCharset::Hex));
348        assert_eq!(
349            SecretCharset::parse("ALPHANUMERIC"),
350            Some(SecretCharset::Alphanumeric)
351        );
352        assert_eq!(SecretCharset::parse("unknown"), None);
353    }
354
355    #[test]
356    fn test_generator_idempotent() {
357        let mut generator = SecretGenerator::new();
358
359        let secret1 = generator.get_or_generate("test", 16);
360        let secret2 = generator.get_or_generate("test", 16);
361
362        assert_eq!(secret1, secret2);
363        assert_eq!(secret1.len(), 16);
364    }
365
366    #[test]
367    fn test_generator_different_names() {
368        let mut generator = SecretGenerator::new();
369
370        let secret1 = generator.get_or_generate("password1", 16);
371        let secret2 = generator.get_or_generate("password2", 16);
372
373        assert_ne!(secret1, secret2);
374    }
375
376    #[test]
377    fn test_generator_charset() {
378        let mut generator = SecretGenerator::new();
379
380        let hex = generator.get_or_generate_with_charset("hex-token", 32, SecretCharset::Hex);
381        assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
382
383        let numeric = generator.get_or_generate_with_charset("pin", 6, SecretCharset::Numeric);
384        assert!(numeric.chars().all(|c| c.is_ascii_digit()));
385    }
386
387    #[test]
388    fn test_state_persistence() {
389        let mut generator1 = SecretGenerator::new();
390        let secret = generator1.get_or_generate("db-password", 24);
391
392        // Simulate persistence
393        let state = generator1.into_state();
394        let json = serde_json::to_string(&state).unwrap();
395
396        // Simulate reload
397        let loaded_state: SecretState = serde_json::from_str(&json).unwrap();
398        let mut generator2 = SecretGenerator::with_state(loaded_state);
399
400        // Should return same secret
401        let secret2 = generator2.get_or_generate("db-password", 24);
402        assert_eq!(secret, secret2);
403        assert!(!generator2.is_dirty()); // Not dirty because secret already existed
404    }
405
406    #[test]
407    fn test_rotate() {
408        let mut generator = SecretGenerator::new();
409
410        let original = generator.get_or_generate("api-key", 32);
411        let rotated = generator.rotate("api-key").unwrap();
412
413        assert_ne!(original, rotated);
414        assert_eq!(rotated.len(), 32);
415
416        // Getting the secret again should return rotated value
417        let current = generator.get_or_generate("api-key", 32);
418        assert_eq!(current, rotated);
419    }
420
421    #[test]
422    fn test_dirty_flag() {
423        let mut generator = SecretGenerator::new();
424        assert!(!generator.is_dirty());
425
426        generator.get_or_generate("new-secret", 16);
427        assert!(generator.is_dirty());
428
429        let mut state = generator.into_state();
430        state.mark_clean();
431        assert!(!state.is_dirty());
432    }
433}