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 FIPS-approved
4//! RNG (`OsRng`). 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::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/// 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 `OsRng` (FIPS-approved on supported platforms) 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        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    /// Hash a plaintext token for lookup.
156    ///
157    /// Used during validation to compute the hash from the client-supplied
158    /// token before querying the store.
159    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        // base64url of 32 bytes = 43 characters
176        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, // 64 bits, below minimum
187            ..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}