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}