Skip to main content

secrets_core/
generators.rs

1//! The concrete secret generator behind a pack's `generated` block — one
2//! implementation shared by start/setup/deployer so a pack mints identical
3//! material everywhere, instead of each repo carrying its own CSPRNG with a
4//! slightly different alphabet/length.
5//!
6//! Faithful to the historical greentic-start `generated_secret_value`: policy
7//! `random`, encodings `raw_text` (a 64-char ASCII alphabet), `base64url`
8//! (URL-safe, no pad), and `hex` (lowercase). `length` is the character count
9//! for `raw_text` and the raw random-byte count for `base64url`/`hex`.
10
11use crate::errors::{Error, Result};
12use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
13use greentic_secrets_spec::GeneratedSecretRequirement;
14use greentic_types::secrets::SecretFormat;
15use rand::{Rng, RngExt};
16
17/// Alphabet for the `raw_text` encoding — `[A-Za-z0-9_-]` (64 chars).
18const RAW_TEXT_ALPHABET: &[u8] =
19    b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-";
20
21/// Upper bound on a generated secret's declared `length`. The value comes from a
22/// pack manifest, so a malformed or hostile pack could otherwise turn a single
23/// integer into an unbounded allocation / CPU sink (`base64url`/`hex` further
24/// scale it). No real secret approaches this; a pack asking for more is rejected
25/// loudly rather than silently clamped.
26const MAX_GENERATED_LENGTH: usize = 4096;
27
28/// Mint a value for a pack-declared generated secret, returning the value bytes
29/// and the [`SecretFormat`] they should be stored under.
30///
31/// Errors with [`Error::Invalid`] on an unsupported policy or encoding, or a
32/// `length` over [`MAX_GENERATED_LENGTH`], matching the runtime's historical
33/// behavior (a malformed pack fails loudly rather than silently producing the
34/// wrong shape). Uses the crate's CSPRNG (`rand::rng()`).
35pub fn generate_secret_value(
36    generated: &GeneratedSecretRequirement,
37) -> Result<(Vec<u8>, SecretFormat)> {
38    if !generated.policy.eq_ignore_ascii_case("random") {
39        return Err(Error::Invalid(
40            "generated secret policy".to_string(),
41            generated.policy.clone(),
42        ));
43    }
44    if generated.length > MAX_GENERATED_LENGTH {
45        return Err(Error::Invalid(
46            "generated secret length".to_string(),
47            format!(
48                "{} exceeds the maximum supported length of {MAX_GENERATED_LENGTH}",
49                generated.length
50            ),
51        ));
52    }
53    let length = generated.length.max(1);
54    let text = match generated.encoding.as_str() {
55        "raw_text" => random_ascii(length),
56        "base64url" => URL_SAFE_NO_PAD.encode(random_bytes(length)),
57        "hex" => hex_encode(&random_bytes(length)),
58        other => {
59            return Err(Error::Invalid(
60                "generated secret encoding".to_string(),
61                other.to_string(),
62            ));
63        }
64    };
65    Ok((text.into_bytes(), SecretFormat::Text))
66}
67
68fn random_ascii(length: usize) -> String {
69    let mut rng = rand::rng();
70    let mut out = String::with_capacity(length);
71    for _ in 0..length {
72        let idx = rng.random_range(0..RAW_TEXT_ALPHABET.len());
73        out.push(RAW_TEXT_ALPHABET[idx] as char);
74    }
75    out
76}
77
78fn random_bytes(len: usize) -> Vec<u8> {
79    let mut buffer = vec![0u8; len];
80    rand::rng().fill_bytes(&mut buffer);
81    buffer
82}
83
84fn hex_encode(bytes: &[u8]) -> String {
85    const HEX: &[u8; 16] = b"0123456789abcdef";
86    let mut out = String::with_capacity(bytes.len() * 2);
87    for &b in bytes {
88        out.push(HEX[(b >> 4) as usize] as char);
89        out.push(HEX[(b & 0x0f) as usize] as char);
90    }
91    out
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use greentic_secrets_spec::GeneratedSecretScope;
98
99    fn spec(encoding: &str, length: usize) -> GeneratedSecretRequirement {
100        GeneratedSecretRequirement {
101            policy: "random".to_string(),
102            length,
103            encoding: encoding.to_string(),
104            scope: GeneratedSecretScope {
105                level: "tenant".to_string(),
106                team: Some("_".to_string()),
107            },
108            regenerate_if_present: false,
109        }
110    }
111
112    #[test]
113    fn raw_text_has_requested_length_and_charset() {
114        let (bytes, fmt) = generate_secret_value(&spec("raw_text", 20)).unwrap();
115        assert_eq!(fmt, SecretFormat::Text);
116        assert_eq!(bytes.len(), 20);
117        assert!(bytes.iter().all(|b| RAW_TEXT_ALPHABET.contains(b)));
118    }
119
120    #[test]
121    fn hex_is_two_chars_per_byte() {
122        let (bytes, _) = generate_secret_value(&spec("hex", 16)).unwrap();
123        assert_eq!(bytes.len(), 32);
124        assert!(bytes.iter().all(|b| b.is_ascii_hexdigit()));
125    }
126
127    #[test]
128    fn base64url_decodes_to_requested_byte_count() {
129        let (b64, _) = generate_secret_value(&spec("base64url", 24)).unwrap();
130        assert_eq!(URL_SAFE_NO_PAD.decode(&b64).unwrap().len(), 24);
131        // URL-safe, no padding.
132        assert!(!b64.contains(&b'='));
133        assert!(!b64.contains(&b'+'));
134        assert!(!b64.contains(&b'/'));
135    }
136
137    #[test]
138    fn two_generations_differ() {
139        let (a, _) = generate_secret_value(&spec("raw_text", 20)).unwrap();
140        let (b, _) = generate_secret_value(&spec("raw_text", 20)).unwrap();
141        assert_ne!(a, b);
142    }
143
144    #[test]
145    fn zero_length_is_clamped_to_one() {
146        let (bytes, _) = generate_secret_value(&spec("raw_text", 0)).unwrap();
147        assert_eq!(bytes.len(), 1);
148    }
149
150    #[test]
151    fn unsupported_policy_and_encoding_error() {
152        let mut bad_policy = spec("raw_text", 20);
153        bad_policy.policy = "fixed".to_string();
154        assert!(matches!(
155            generate_secret_value(&bad_policy),
156            Err(Error::Invalid(_, _))
157        ));
158
159        assert!(matches!(
160            generate_secret_value(&spec("uuid", 20)),
161            Err(Error::Invalid(_, _))
162        ));
163    }
164
165    #[test]
166    fn over_max_length_is_rejected_and_boundary_is_accepted() {
167        let mut over = spec("raw_text", 20);
168        over.length = MAX_GENERATED_LENGTH + 1;
169        assert!(matches!(
170            generate_secret_value(&over),
171            Err(Error::Invalid(_, _))
172        ));
173
174        // The boundary value is still minted.
175        let mut at_max = spec("raw_text", 20);
176        at_max.length = MAX_GENERATED_LENGTH;
177        let (bytes, _) = generate_secret_value(&at_max).unwrap();
178        assert_eq!(bytes.len(), MAX_GENERATED_LENGTH);
179    }
180}