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
77/// LDAP backend configuration for OTP storage (RHELBU-3536 R7).
78///
79/// ```toml
80/// [otp.ldap]
81/// url = "ldaps://ipa.example.com"
82/// bind_dn = "uid=kipuka,cn=sysaccounts,cn=etc,dc=example,dc=com"
83/// bind_password = "env:KIPUKA_LDAP_BIND_PW"
84/// base_dn = "cn=otp,cn=kipuka,dc=example,dc=com"
85/// ```
86#[derive(Debug, Clone, Deserialize)]
87#[serde(deny_unknown_fields)]
88pub struct OtpLdapConfig {
89 /// LDAP server URL (`ldap://` or `ldaps://`).
90 pub url: String,
91
92 /// Bind DN for LDAP authentication.
93 pub bind_dn: String,
94
95 /// Bind password. Supports `"env:VAR_NAME"` for env-var expansion.
96 #[serde(default)]
97 pub bind_password: String,
98
99 /// Base DN under which OTP entries are stored.
100 pub base_dn: String,
101
102 /// LDAP attribute name for the OTP value.
103 /// Default: `"kipukaOtp"`.
104 #[serde(default = "default_otp_attribute")]
105 pub otp_attribute: String,
106
107 /// Connection timeout in seconds.
108 #[serde(default = "default_ldap_timeout_secs")]
109 pub timeout_secs: u64,
110
111 /// Use STARTTLS over a plain LDAP connection.
112 #[serde(default)]
113 pub starttls: bool,
114}
115
116fn default_entropy_bits() -> u32 {
117 128
118}
119
120fn default_ttl_seconds() -> u64 {
121 3600
122}
123
124fn default_max_usage() -> u32 {
125 1
126}
127
128fn default_otp_attribute() -> String {
129 "kipukaOtp".to_string()
130}
131
132fn default_ldap_timeout_secs() -> u64 {
133 10
134}
135
136impl Default for OtpConfig {
137 fn default() -> Self {
138 Self {
139 enabled: false,
140 entropy_bits: default_entropy_bits(),
141 ttl_seconds: default_ttl_seconds(),
142 max_usage: default_max_usage(),
143 storage_backend: OtpStorageBackend::default(),
144 ldap: None,
145 }
146 }
147}
148
149impl OtpConfig {
150 /// Validate OTP configuration constraints.
151 pub fn validate(&self) -> std::result::Result<(), String> {
152 if !self.enabled {
153 return Ok(());
154 }
155
156 if self.entropy_bits < 128 {
157 return Err(format!(
158 "[otp].entropy_bits must be at least 128, got {}",
159 self.entropy_bits
160 ));
161 }
162
163 if self.ttl_seconds == 0 {
164 return Err("[otp].ttl_seconds must be at least 1".into());
165 }
166
167 if self.max_usage == 0 {
168 return Err("[otp].max_usage must be at least 1".into());
169 }
170
171 if self.storage_backend == OtpStorageBackend::Ldap && self.ldap.is_none() {
172 return Err("[otp].ldap section is required when storage_backend = \"ldap\"".into());
173 }
174
175 if let Some(ref ldap) = self.ldap {
176 if ldap.url.is_empty() {
177 return Err("[otp.ldap].url must not be empty".into());
178 }
179 if ldap.bind_dn.is_empty() {
180 return Err("[otp.ldap].bind_dn must not be empty".into());
181 }
182 if ldap.base_dn.is_empty() {
183 return Err("[otp.ldap].base_dn must not be empty".into());
184 }
185 }
186
187 Ok(())
188 }
189}