Skip to main content

kipuka/config/
otp.rs

1//! One-Time Password (OTP) configuration for EST enrollment.
2//!
3//! OTP authentication provides an alternative to mTLS for initial device
4//! enrollment (RHELBU-3536 R7).  An administrator generates an OTP via
5//! the admin API, which the client presents in an HTTP Basic Authorization
6//! header alongside its CSR.
7//!
8//! OTP storage backends:
9//!
10//! - **db** — OTPs are stored in the Kipuka database.
11//! - **ldap** — OTPs are stored as attributes on LDAP entries, enabling
12//!   integration with FreeIPA or Active Directory enrollment workflows.
13
14use serde::Deserialize;
15
16/// OTP storage backend.
17#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
18#[serde(rename_all = "lowercase")]
19#[derive(Default)]
20pub enum OtpStorageBackend {
21    /// Store OTPs in the Kipuka database.
22    #[default]
23    Db,
24    /// Store OTPs in an LDAP directory.
25    Ldap,
26}
27
28/// `[otp]` section — OTP enrollment authentication configuration.
29///
30/// ```toml
31/// [otp]
32/// enabled = true
33/// entropy_bits = 128
34/// ttl_seconds = 3600
35/// max_usage = 1
36/// storage_backend = "db"
37/// ```
38#[derive(Debug, Clone, Deserialize)]
39#[serde(deny_unknown_fields)]
40pub struct OtpConfig {
41    /// Enable OTP-based enrollment authentication.
42    #[serde(default)]
43    pub enabled: bool,
44
45    /// Minimum entropy bits for generated OTPs.
46    ///
47    /// NIST SP 800-63B requires at least 112 bits for authenticator
48    /// secrets; the Kipuka default is 128 bits for a comfortable margin.
49    /// Values below 128 are rejected during validation.
50    #[serde(default = "default_entropy_bits")]
51    pub entropy_bits: u32,
52
53    /// Time-to-live for OTPs in seconds.
54    ///
55    /// After this duration, unused OTPs are automatically invalidated.
56    /// Default: 3600 (1 hour).
57    #[serde(default = "default_ttl_seconds")]
58    pub ttl_seconds: u64,
59
60    /// Maximum number of times an OTP can be used before it is consumed.
61    ///
62    /// `1` (the default) enforces single-use semantics.  Values greater
63    /// than 1 allow re-enrollment within the TTL window (e.g., for
64    /// retry after transient failure).
65    #[serde(default = "default_max_usage")]
66    pub max_usage: u32,
67
68    /// Storage backend for OTP records.
69    #[serde(default)]
70    pub storage_backend: OtpStorageBackend,
71
72    /// LDAP connection configuration (required when `storage_backend = "ldap"`).
73    #[serde(default)]
74    pub ldap: Option<OtpLdapConfig>,
75
76    /// Interval in seconds between expired OTP cleanup sweeps.
77    ///
78    /// Default: 300 (5 minutes).
79    #[serde(default = "default_cleanup_interval_secs")]
80    pub cleanup_interval_secs: u64,
81}
82
83/// LDAP backend configuration for OTP storage (RHELBU-3536 R7).
84///
85/// ```toml
86/// [otp.ldap]
87/// url = "ldaps://ipa.example.com"
88/// bind_dn = "uid=kipuka,cn=sysaccounts,cn=etc,dc=example,dc=com"
89/// bind_password = "env:KIPUKA_LDAP_BIND_PW"
90/// base_dn = "cn=otp,cn=kipuka,dc=example,dc=com"
91/// ```
92#[derive(Debug, Clone, Deserialize)]
93#[serde(deny_unknown_fields)]
94pub struct OtpLdapConfig {
95    /// LDAP server URL (`ldap://` or `ldaps://`).
96    pub url: String,
97
98    /// Bind DN for LDAP authentication.
99    pub bind_dn: String,
100
101    /// Bind password. Supports secret prefixes: `env:VAR`, `file:/path`,
102    /// `prompt:Label`, `keyring:name`, `systemd-creds:name`.
103    #[serde(default)]
104    pub bind_password: super::SecretRef,
105
106    /// Base DN under which OTP entries are stored.
107    pub base_dn: String,
108
109    /// LDAP attribute name for the OTP value.
110    /// Default: `"kipukaOtp"`.
111    #[serde(default = "default_otp_attribute")]
112    pub otp_attribute: String,
113
114    /// Connection timeout in seconds.
115    #[serde(default = "default_ldap_timeout_secs")]
116    pub timeout_secs: u64,
117
118    /// Use STARTTLS over a plain LDAP connection.
119    #[serde(default)]
120    pub starttls: bool,
121}
122
123fn default_entropy_bits() -> u32 {
124    128
125}
126
127fn default_ttl_seconds() -> u64 {
128    3600
129}
130
131fn default_max_usage() -> u32 {
132    1
133}
134
135fn default_otp_attribute() -> String {
136    "kipukaOtp".to_string()
137}
138
139fn default_ldap_timeout_secs() -> u64 {
140    10
141}
142
143fn default_cleanup_interval_secs() -> u64 {
144    300
145}
146
147impl Default for OtpConfig {
148    fn default() -> Self {
149        Self {
150            enabled: false,
151            entropy_bits: default_entropy_bits(),
152            ttl_seconds: default_ttl_seconds(),
153            max_usage: default_max_usage(),
154            storage_backend: OtpStorageBackend::default(),
155            ldap: None,
156            cleanup_interval_secs: default_cleanup_interval_secs(),
157        }
158    }
159}
160
161impl OtpConfig {
162    /// Validate OTP configuration constraints.
163    pub fn validate(&self) -> std::result::Result<(), String> {
164        if !self.enabled {
165            return Ok(());
166        }
167
168        if self.entropy_bits < 128 {
169            return Err(format!(
170                "[otp].entropy_bits must be at least 128, got {}",
171                self.entropy_bits
172            ));
173        }
174
175        if self.ttl_seconds == 0 {
176            return Err("[otp].ttl_seconds must be at least 1".into());
177        }
178
179        if self.max_usage == 0 {
180            return Err("[otp].max_usage must be at least 1".into());
181        }
182
183        if self.cleanup_interval_secs == 0 {
184            return Err("[otp].cleanup_interval_secs must be at least 1".into());
185        }
186
187        if self.storage_backend == OtpStorageBackend::Ldap && self.ldap.is_none() {
188            return Err("[otp].ldap section is required when storage_backend = \"ldap\"".into());
189        }
190
191        if let Some(ref ldap) = self.ldap {
192            if ldap.url.is_empty() {
193                return Err("[otp.ldap].url must not be empty".into());
194            }
195            if ldap.bind_dn.is_empty() {
196                return Err("[otp.ldap].bind_dn must not be empty".into());
197            }
198            if ldap.base_dn.is_empty() {
199                return Err("[otp.ldap].base_dn must not be empty".into());
200            }
201        }
202
203        Ok(())
204    }
205}