api_keys_simplified/
config.rs

1use crate::error::ConfigError;
2use derive_getters::Getters;
3use lazy_static::lazy_static;
4use regex::Regex;
5use strum::{Display, EnumIter, EnumString};
6use strum::{IntoEnumIterator, IntoStaticStr};
7
8/// Key version for backward compatibility and migration.
9/// Allows different key formats to coexist during transitions.
10///
11/// Version 0 represents no explicit version (backward compatible with existing keys).
12/// Format: prefix{sep}env{sep}base64[.checksum]
13///
14/// Versions 1+ will have version between prefix and environment:
15/// Format: prefix{sep}v{N}{sep}env{sep}base64[.checksum]
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
17pub struct KeyVersion(u32);
18
19impl KeyVersion {
20    /// No version in key
21    /// Format: prefix-env-data.checksum
22    pub const NONE: Self = KeyVersion(0);
23
24    /// Version 1 - First versioned format
25    /// Format: prefix-v1-env-data.checksum
26    pub const V1: Self = KeyVersion(1);
27
28    /// Version 2
29    /// Format: prefix-v2-env-data.checksum
30    pub const V2: Self = KeyVersion(2);
31
32    /// Creates a new key version with the given number
33    pub const fn new(version: u32) -> Self {
34        KeyVersion(version)
35    }
36
37    /// Returns the version number
38    pub const fn number(&self) -> u32 {
39        self.0
40    }
41
42    /// Returns true if this version should be included in the key
43    pub const fn is_versioned(&self) -> bool {
44        self.0 > 0
45    }
46
47    /// Returns the version component string for key generation
48    /// Returns empty string for version 0 (backward compatibility)
49    pub fn component(&self) -> String {
50        if self.0 == 0 {
51            String::new()
52        } else {
53            format!("v{}", self.0)
54        }
55    }
56}
57
58impl Default for KeyVersion {
59    fn default() -> Self {
60        KeyVersion::NONE
61    }
62}
63
64impl std::fmt::Display for KeyVersion {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        if self.0 == 0 {
67            write!(f, "unversioned")
68        } else {
69            write!(f, "v{}", self.0)
70        }
71    }
72}
73
74/// Deployment environment for API keys (dev/test/staging/live).
75/// Used to visually distinguish keys across different environments and prevent accidental misuse
76/// And allow users to set different Rate limits based on Environment.
77#[derive(Debug, Clone, PartialEq, Eq, EnumIter, EnumString, Display, IntoStaticStr)]
78pub enum Environment {
79    #[strum(serialize = "dev")]
80    Development,
81    #[strum(serialize = "test")]
82    Test,
83    #[strum(serialize = "staging")]
84    Staging,
85    #[strum(serialize = "live")]
86    Production,
87}
88
89lazy_static! {
90    static ref ENVIRONMENT_VARIANTS: Vec<Environment> = Environment::iter().collect();
91    // Regex to detect version patterns: 'v' followed by one or more digits
92    static ref VERSION_PATTERN: Regex = Regex::new(r"v\d+").unwrap();
93}
94
95impl Environment {
96    pub fn dev() -> Self {
97        Environment::Development
98    }
99    pub fn test() -> Self {
100        Environment::Test
101    }
102    pub fn staging() -> Self {
103        Environment::Staging
104    }
105    pub fn production() -> Self {
106        Environment::Production
107    }
108    pub fn variants() -> &'static [Environment] {
109        &ENVIRONMENT_VARIANTS
110    }
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct KeyPrefix(String);
115
116impl KeyPrefix {
117    pub fn new(prefix: impl Into<String>) -> std::result::Result<Self, ConfigError> {
118        let prefix = prefix.into();
119        if prefix.is_empty() || prefix.len() > 20 {
120            return Err(ConfigError::InvalidPrefixLength);
121        }
122        if !prefix
123            .chars()
124            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
125        {
126            return Err(ConfigError::InvalidPrefixCharacters);
127        }
128        if let Some(invalid) = Environment::variants().iter().find(|v| {
129            let s: &'static str = (*v).into();
130            prefix.contains(s)
131        }) {
132            return Err(ConfigError::InvalidPrefixSubstring(invalid.to_string()));
133        }
134
135        // Prevent prefixes that contain version patterns (e.g., "v1", "v2", "apiv42", "myv1key")
136        // This would conflict with the version component in the key format
137        if VERSION_PATTERN.is_match(&prefix) {
138            return Err(ConfigError::InvalidPrefixVersionLike);
139        }
140
141        Ok(Self(prefix))
142    }
143
144    pub fn as_str(&self) -> &str {
145        &self.0
146    }
147}
148
149/// Separator character for API key components (prefix, environment and data).
150#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumString, IntoStaticStr, Default)]
151pub enum Separator {
152    #[strum(serialize = "/")]
153    Slash,
154
155    #[strum(serialize = "-")]
156    #[default]
157    Dash,
158
159    #[strum(serialize = "~")]
160    Tilde,
161}
162
163#[derive(Debug, Clone, Getters)]
164pub struct HashConfig {
165    memory_cost: u32,
166    time_cost: u32,
167    parallelism: u32,
168}
169
170impl HashConfig {
171    /// Creates a custom HashConfig with validated parameters.
172    pub fn custom(
173        memory_cost: u32,
174        time_cost: u32,
175        parallelism: u32,
176    ) -> std::result::Result<Self, ConfigError> {
177        // Verify parameters are accepted by Argon2 library
178        // Bad idea to do it here.. but we'll keep it here for now
179        argon2::Params::new(memory_cost, time_cost, parallelism, None)
180            .map_err(|_| ConfigError::InvalidHashParams)?;
181
182        Ok(Self {
183            memory_cost,
184            time_cost,
185            parallelism,
186        })
187    }
188
189    /// Balanced preset for general production use.
190    ///
191    /// - Memory: 46 MB
192    /// - Time: 1 iterations
193    /// - Parallelism: 1 threads
194    ///   Default recommendation according to
195    ///   [OWASP](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#argon2id)
196    ///   Refer the document for best practices at different memory cost.
197    pub fn balanced() -> Self {
198        Self {
199            memory_cost: 47_104,
200            time_cost: 1,
201            parallelism: 1,
202        }
203    }
204
205    /// High security preset for sensitive operations.
206    ///
207    /// - Memory: 64 MB
208    /// - Time: 2 iterations
209    /// - Parallelism: 4 threads
210    ///   Higher limits then what's suggested in
211    ///   [OWASP](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#argon2id)
212    pub fn high_security() -> Self {
213        Self {
214            memory_cost: 65_536,
215            time_cost: 3,
216            parallelism: 4,
217        }
218    }
219}
220
221impl Default for HashConfig {
222    fn default() -> Self {
223        Self::balanced()
224    }
225}
226
227#[derive(Default, Debug, Clone, IntoStaticStr)]
228pub enum ChecksumAlgo {
229    /// Cryptographic yet fast
230    /// hashing algo, suitable for
231    /// quick checksum verification.
232    #[default]
233    #[strum(serialize = "b3")]
234    Black3,
235}
236
237#[derive(Debug, Clone, Getters)]
238pub struct KeyConfig {
239    entropy_bytes: usize,
240    checksum_length: usize,
241    separator: Separator,
242    checksum_algorithm: ChecksumAlgo,
243    version: KeyVersion,
244}
245
246impl KeyConfig {
247    pub fn new() -> Self {
248        Self::default()
249    }
250
251    pub fn with_entropy(mut self, bytes: usize) -> std::result::Result<Self, ConfigError> {
252        if bytes < 16 {
253            return Err(ConfigError::EntropyTooLow);
254        }
255        if bytes > 64 {
256            return Err(ConfigError::EntropyTooHigh);
257        }
258        self.entropy_bytes = bytes;
259        Ok(self)
260    }
261
262    pub fn checksum(mut self, bytes: usize) -> Result<Self, ConfigError> {
263        match &self.checksum_algorithm {
264            ChecksumAlgo::Black3 => {
265                if bytes < 32 {
266                    return Err(ConfigError::ChecksumLenTooSmall);
267                }
268                if bytes > 64 {
269                    return Err(ConfigError::ChecksumLenTooLarge);
270                }
271            }
272        }
273        self.checksum_length = bytes;
274        Ok(self)
275    }
276
277    pub fn disable_checksum(mut self) -> Self {
278        self.checksum_length = 0;
279        self
280    }
281
282    pub fn with_separator(mut self, separator: Separator) -> Self {
283        self.separator = separator;
284        self
285    }
286
287    pub fn with_version(mut self, version: KeyVersion) -> Self {
288        self.version = version;
289        self
290    }
291
292    pub fn balanced() -> Self {
293        Self {
294            entropy_bytes: 24,
295            checksum_length: 20,
296            separator: Separator::default(),
297            checksum_algorithm: ChecksumAlgo::default(),
298            version: KeyVersion::default(),
299        }
300    }
301
302    pub fn high_security() -> Self {
303        Self {
304            entropy_bytes: 64,
305            checksum_length: 32,
306            separator: Separator::default(),
307            checksum_algorithm: ChecksumAlgo::default(),
308            version: KeyVersion::default(),
309        }
310    }
311}
312
313impl Default for KeyConfig {
314    fn default() -> Self {
315        Self::balanced()
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use std::str::FromStr;
323
324    #[test]
325    fn test_prefix_validation() {
326        assert!(KeyPrefix::new("sk").is_ok());
327        assert!(KeyPrefix::new("api_key").is_ok());
328        assert!(KeyPrefix::new("").is_err());
329        assert!(KeyPrefix::new("invalid-prefix").is_ok());
330    }
331
332    #[test]
333    fn test_prefix_cannot_be_version_like() {
334        // Should reject any prefix containing version patterns (v followed by digits)
335        assert!(KeyPrefix::new("v1").is_err());
336        assert!(KeyPrefix::new("v2").is_err());
337        assert!(KeyPrefix::new("v42").is_err());
338        assert!(KeyPrefix::new("v100").is_err());
339        assert!(KeyPrefix::new("v0").is_err());
340        assert!(KeyPrefix::new("apiv1").is_err());
341        assert!(KeyPrefix::new("apiv2").is_err());
342        assert!(KeyPrefix::new("myv42key").is_err());
343        assert!(KeyPrefix::new("testv1").is_err());
344        assert!(KeyPrefix::new("v1beta").is_err());
345        assert!(KeyPrefix::new("betav1").is_err());
346        assert!(KeyPrefix::new("keyv123end").is_err());
347
348        // Should allow prefixes without version patterns
349        assert!(KeyPrefix::new("version").is_ok());
350        assert!(KeyPrefix::new("vault").is_ok());
351        assert!(KeyPrefix::new("v_key").is_ok());
352        assert!(KeyPrefix::new("vkey").is_ok());
353        assert!(KeyPrefix::new("api").is_ok());
354        assert!(KeyPrefix::new("sk").is_ok());
355        assert!(KeyPrefix::new("versionkey").is_ok());
356        assert!(KeyPrefix::new("apiversion").is_ok());
357        // Edge case: just 'v' should be allowed
358        assert!(KeyPrefix::new("v").is_ok());
359    }
360
361    #[test]
362    fn test_config_validation() {
363        assert!(KeyConfig::new().with_entropy(32).is_ok());
364        assert!(KeyConfig::new().with_entropy(8).is_err());
365        assert!(KeyConfig::new().with_entropy(128).is_err());
366    }
367
368    #[test]
369    fn test_separator_display() {
370        let slash: &'static str = Separator::Slash.into();
371        let dash: &'static str = Separator::Dash.into();
372        let tilde: &'static str = Separator::Tilde.into();
373        assert_eq!(slash, "/");
374        assert_eq!(dash, "-");
375        assert_eq!(tilde, "~");
376    }
377
378    #[test]
379    fn test_separator_from_str() {
380        assert_eq!(Separator::from_str("/").unwrap(), Separator::Slash);
381        assert_eq!(Separator::from_str("-").unwrap(), Separator::Dash);
382        assert_eq!(Separator::from_str("~").unwrap(), Separator::Tilde);
383        assert!(Separator::from_str(".").is_err());
384    }
385
386    #[test]
387    fn test_separator_default() {
388        assert_eq!(Separator::default(), Separator::Dash);
389    }
390
391    #[test]
392    fn test_key_config_with_separator() {
393        let config = KeyConfig::new().with_separator(Separator::Dash);
394        assert_eq!(config.separator, Separator::Dash);
395
396        let config = KeyConfig::new().with_separator(Separator::Tilde);
397        assert_eq!(config.separator, Separator::Tilde);
398    }
399}