1use base64::Engine;
8use base64::engine::general_purpose::URL_SAFE_NO_PAD;
9use chrono::{DateTime, Duration, Utc};
10use rand::RngCore;
11use rand::rngs::OsRng;
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 OsRng.fill_bytes(&mut raw);
122
123 let plaintext_token = URL_SAFE_NO_PAD.encode(&raw);
124 let token_hash = Sha256::digest(plaintext_token.as_bytes()).to_vec();
125
126 let now = Utc::now();
127 let expires_at = now + Duration::seconds(ttl_seconds);
128
129 let metadata = OtpMetadata {
130 id: Uuid::new_v4(),
131 entity_id: entity_id.to_owned(),
132 label: label.to_owned(),
133 profile: profile.to_owned(),
134 created_at: now,
135 expires_at,
136 max_uses,
137 };
138
139 debug!(
140 id = %metadata.id,
141 entity_id = %entity_id,
142 label = %label,
143 profile = %profile,
144 expires_at = %metadata.expires_at,
145 "generated OTP token"
146 );
147
148 Ok(GeneratedOtp {
149 plaintext_token,
150 token_hash,
151 metadata,
152 })
153 }
154
155 pub fn hash_token(plaintext: &str) -> Vec<u8> {
160 Sha256::digest(plaintext.as_bytes()).to_vec()
161 }
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 #[test]
169 fn generated_token_meets_minimum_entropy() {
170 let generator = OtpGenerator::new(OtpGeneratorConfig::default()).unwrap();
171 let otp = generator
172 .generate("host.example.com", "test", "default")
173 .unwrap();
174
175 assert!(
177 otp.plaintext_token.len() >= 22,
178 "token too short for 128-bit entropy"
179 );
180 assert_eq!(otp.token_hash.len(), 32, "SHA-256 hash should be 32 bytes");
181 }
182
183 #[test]
184 fn rejects_insufficient_entropy() {
185 let config = OtpGeneratorConfig {
186 entropy_bytes: 8, ..Default::default()
188 };
189 assert!(OtpGenerator::new(config).is_err());
190 }
191
192 #[test]
193 fn hash_is_deterministic() {
194 let a = OtpGenerator::hash_token("test-token");
195 let b = OtpGenerator::hash_token("test-token");
196 assert_eq!(a, b);
197 }
198}