1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct OtpGeneratorConfig {
22 pub entropy_bytes: usize,
24 pub default_ttl_seconds: i64,
26 pub default_max_uses: u32,
28}
29
30impl Default for OtpGeneratorConfig {
31 fn default() -> Self {
32 Self {
33 entropy_bytes: 32, default_ttl_seconds: 3600, default_max_uses: 1,
36 }
37 }
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct OtpMetadata {
43 pub id: Uuid,
45 pub entity_id: String,
47 pub label: String,
49 pub profile: String,
51 pub created_at: DateTime<Utc>,
53 pub expires_at: DateTime<Utc>,
55 pub max_uses: u32,
57}
58
59pub struct GeneratedOtp {
61 pub plaintext_token: String,
65 pub token_hash: Vec<u8>,
67 pub metadata: OtpMetadata,
69}
70
71pub struct OtpGenerator {
76 config: OtpGeneratorConfig,
77}
78
79impl OtpGenerator {
80 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 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 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
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 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 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, ..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}