Skip to main content

kipuka_otp/
generate.rs

1//! OTP token generation with configurable entropy.
2//!
3//! Implements RHELBU-3536 R7: minimum 128-bit entropy using the OS CSPRNG
4//! (`SysRng`, getrandom-backed). Tokens are base64url-encoded for safe embedding in
5//! HTTP headers and URIs.
6
7use base64::Engine;
8use base64::engine::general_purpose::URL_SAFE_NO_PAD;
9use chrono::{DateTime, Duration, Utc};
10use rand::TryRng;
11use rand::rngs::SysRng;
12use serde::{Deserialize, Serialize};
13use sha2::{Digest, Sha256};
14use tracing::debug;
15use uuid::Uuid;
16
17use crate::{OtpError, OtpResult};
18
19/// Configuration for the OTP generator.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct OtpGeneratorConfig {
22    /// Number of random bytes to generate (minimum 16 = 128 bits per R7).
23    pub entropy_bytes: usize,
24    /// Default token lifetime.
25    pub default_ttl_seconds: i64,
26    /// Default maximum usage count (1 = single-use).
27    pub default_max_uses: u32,
28}
29
30impl Default for OtpGeneratorConfig {
31    fn default() -> Self {
32        Self {
33            entropy_bytes: 32,         // 256 bits, well above the 128-bit minimum
34            default_ttl_seconds: 3600, // 1 hour
35            default_max_uses: 1,
36        }
37    }
38}
39
40/// Metadata attached to a generated OTP token.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct OtpMetadata {
43    /// Unique record identifier.
44    pub id: Uuid,
45    /// Entity (host, user, service) this OTP authorizes enrollment for.
46    pub entity_id: String,
47    /// Human-readable label for the OTP.
48    pub label: String,
49    /// Enrollment profile to apply when this OTP is consumed.
50    pub profile: String,
51    /// Timestamp when the OTP was created.
52    pub created_at: DateTime<Utc>,
53    /// Timestamp when the OTP expires.
54    pub expires_at: DateTime<Utc>,
55    /// Maximum number of times this OTP may be used.
56    pub max_uses: u32,
57}
58
59/// Result of generating a new OTP.
60pub struct GeneratedOtp {
61    /// The plaintext token value to deliver to the enrollee.
62    /// This is the only time the plaintext is available; the store
63    /// receives only the SHA-256 hash.
64    pub plaintext_token: String,
65    /// SHA-256 hash of the token for storage.
66    pub token_hash: Vec<u8>,
67    /// Metadata for the OTP record.
68    pub metadata: OtpMetadata,
69}
70
71/// Generates cryptographically random OTP tokens.
72///
73/// Uses the OS CSPRNG (`SysRng`, getrandom-backed) to produce
74/// tokens with at least 128 bits of entropy (RHELBU-3536 R7).
75pub struct OtpGenerator {
76    config: OtpGeneratorConfig,
77}
78
79impl OtpGenerator {
80    /// Create a generator with the given configuration.
81    ///
82    /// # Errors
83    ///
84    /// Returns [`OtpError::GenerationError`] if `entropy_bytes < 16`
85    /// (below the 128-bit minimum required by RHELBU-3536 R7).
86    pub fn new(config: OtpGeneratorConfig) -> OtpResult<Self> {
87        if config.entropy_bytes < 16 {
88            return Err(OtpError::GenerationError(format!(
89                "entropy_bytes {} is below the 128-bit (16-byte) minimum per RHELBU-3536 R7",
90                config.entropy_bytes
91            )));
92        }
93        Ok(Self { config })
94    }
95
96    /// Generate a new OTP for the given entity and profile.
97    ///
98    /// Returns a [`GeneratedOtp`] containing the plaintext token (for
99    /// delivery to the enrollee) and the SHA-256 hash (for storage).
100    /// The plaintext must not be persisted by the caller.
101    pub fn generate(&self, entity_id: &str, label: &str, profile: &str) -> OtpResult<GeneratedOtp> {
102        self.generate_with_options(
103            entity_id,
104            label,
105            profile,
106            self.config.default_ttl_seconds,
107            self.config.default_max_uses,
108        )
109    }
110
111    /// Generate an OTP with explicit TTL and max-use overrides.
112    pub fn generate_with_options(
113        &self,
114        entity_id: &str,
115        label: &str,
116        profile: &str,
117        ttl_seconds: i64,
118        max_uses: u32,
119    ) -> OtpResult<GeneratedOtp> {
120        let mut raw = vec![0u8; self.config.entropy_bytes];
121        // SysRng draws from the OS CSPRNG (getrandom); fallible because OS
122        // entropy retrieval can fail. Fail closed rather than emit a
123        // low-entropy token.
124        SysRng
125            .try_fill_bytes(&mut raw)
126            .map_err(|e| OtpError::GenerationError(format!("OS RNG failed: {e}")))?;
127
128        let plaintext_token = URL_SAFE_NO_PAD.encode(&raw);
129        let token_hash = Sha256::digest(plaintext_token.as_bytes()).to_vec();
130
131        let now = Utc::now();
132        let expires_at = now + Duration::seconds(ttl_seconds);
133
134        let metadata = OtpMetadata {
135            id: Uuid::new_v4(),
136            entity_id: entity_id.to_owned(),
137            label: label.to_owned(),
138            profile: profile.to_owned(),
139            created_at: now,
140            expires_at,
141            max_uses,
142        };
143
144        debug!(
145            id = %metadata.id,
146            entity_id = %entity_id,
147            label = %label,
148            profile = %profile,
149            expires_at = %metadata.expires_at,
150            "generated OTP token"
151        );
152
153        Ok(GeneratedOtp {
154            plaintext_token,
155            token_hash,
156            metadata,
157        })
158    }
159
160    /// Hash a plaintext token for lookup.
161    ///
162    /// Used during validation to compute the hash from the client-supplied
163    /// token before querying the store.
164    pub fn hash_token(plaintext: &str) -> Vec<u8> {
165        Sha256::digest(plaintext.as_bytes()).to_vec()
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn generated_token_meets_minimum_entropy() {
175        let generator = OtpGenerator::new(OtpGeneratorConfig::default()).unwrap();
176        let otp = generator
177            .generate("host.example.com", "test", "default")
178            .unwrap();
179
180        // base64url of 32 bytes = 43 characters
181        assert!(
182            otp.plaintext_token.len() >= 22,
183            "token too short for 128-bit entropy"
184        );
185        assert_eq!(otp.token_hash.len(), 32, "SHA-256 hash should be 32 bytes");
186    }
187
188    #[test]
189    fn rejects_insufficient_entropy() {
190        let config = OtpGeneratorConfig {
191            entropy_bytes: 8, // 64 bits, below minimum
192            ..Default::default()
193        };
194        assert!(OtpGenerator::new(config).is_err());
195    }
196
197    #[test]
198    fn hash_is_deterministic() {
199        let a = OtpGenerator::hash_token("test-token");
200        let b = OtpGenerator::hash_token("test-token");
201        assert_eq!(a, b);
202    }
203}