Skip to main content

kipuka/auth/
otp.rs

1//! HTTP Basic authentication with One-Time Password for EST enrollment.
2//!
3//! RHELBU-3536 R7: EST clients can authenticate using an OTP presented
4//! in the HTTP Basic `Authorization` header.  The username field carries
5//! the entity identifier; the password field carries the OTP value.
6//!
7//! OTPs are generated via the admin API (`POST /admin/otp/generate`) and
8//! stored in the configured backend (database or LDAP).  Each OTP has:
9//!
10//! - An entity-id (the device or service being enrolled)
11//! - An expiry timestamp
12//! - A maximum usage count (typically 1 for single-use)
13//! - A current usage counter
14//!
15//! ## RFC 7617 compliance
16//!
17//! The HTTP Basic authentication scheme follows RFC 7617:
18//!
19//! - **Section 2**: `user-id:password` encoding with UTF-8 support.
20//! - **Section 2.1**: null bytes are rejected for security.
21//! - **Section 2.2**: `WWW-Authenticate` challenges include `charset="UTF-8"`.
22
23use std::sync::Arc;
24
25use axum::http::header::{AUTHORIZATION, RETRY_AFTER, WWW_AUTHENTICATE};
26use axum::http::request::Parts;
27use axum::http::{HeaderValue, StatusCode};
28use axum::response::{IntoResponse, Response};
29use base64::Engine as _;
30use sha2::{Digest, Sha256};
31use subtle::ConstantTimeEq;
32use tracing::{debug, warn};
33
34use super::failure_tracker::LockoutStatus;
35use super::{AuthMethod, AuthResult};
36use crate::state::AppState;
37
38/// Attempt to extract and validate HTTP Basic (OTP) credentials.
39///
40/// Returns:
41/// - `Some(Ok(AuthResult))` — valid OTP, authentication succeeded
42/// - `Some(Err(Response))` — credentials present but invalid (401)
43/// - `None` — no HTTP Basic header present (try next auth method)
44pub async fn try_extract_otp(
45    parts: &Parts,
46    app: &Arc<AppState>,
47) -> Option<Result<AuthResult, Response>> {
48    let auth_header = parts.headers.get(AUTHORIZATION)?.to_str().ok()?;
49
50    // Only handle Basic auth; Negotiate is handled by the GSSAPI module.
51    let credentials_b64 = auth_header.strip_prefix("Basic ")?;
52
53    let decoded = match base64::engine::general_purpose::STANDARD.decode(credentials_b64) {
54        Ok(d) => d,
55        Err(_) => {
56            return Some(Err(unauthorized_response("malformed Basic auth encoding")));
57        }
58    };
59
60    // RFC 7617 §2.1: reject null bytes in credentials (security).
61    if decoded.contains(&0x00) {
62        return Some(Err(unauthorized_response(
63            "Basic auth credentials contain null byte (rejected for security)",
64        )));
65    }
66
67    // RFC 7617 §2.1: decode as UTF-8.
68    let credentials = match String::from_utf8(decoded) {
69        Ok(s) => s,
70        Err(_) => {
71            return Some(Err(unauthorized_response(
72                "Basic auth credentials are not valid UTF-8 (RFC 7617 §2.1)",
73            )));
74        }
75    };
76
77    // RFC 7617 §2: username and password are separated by the first colon.
78    let (entity_id, otp_value) = match credentials.split_once(':') {
79        Some((u, p)) => (u.to_string(), p.to_string()),
80        None => {
81            return Some(Err(unauthorized_response(
82                "malformed Basic auth credentials (missing ':' separator, RFC 7617 §2)",
83            )));
84        }
85    };
86
87    // RFC 7617 §2: user-id MUST NOT be empty.
88    if entity_id.is_empty() {
89        return Some(Err(unauthorized_response(
90            "entity-id must not be empty (RFC 7617 §2)",
91        )));
92    }
93
94    if otp_value.is_empty() {
95        return Some(Err(unauthorized_response("OTP value must not be empty")));
96    }
97
98    debug!(entity_id = %entity_id, "validating OTP for entity");
99
100    // FIA_AFL.1: refuse a locked-out identity *before* validating the
101    // credential.  A locked account must be denied even if it now presents a
102    // correct OTP — the whole point of the control is to deny during the
103    // lockout window.  We key on the claimed entity-id, never on a spoofable
104    // network address.
105    if let LockoutStatus::LockedOut { retry_after } = app.failure_tracker.check(&entity_id) {
106        warn!(entity_id = %entity_id, "OTP attempt refused: identity is locked out");
107        return Some(Err(locked_out_response(retry_after)));
108    }
109
110    // Validate OTP against the configured store.
111    match validate_otp(app, &entity_id, &otp_value).await {
112        Ok(()) => {
113            // OTP is valid and has been consumed.  Clear any failure state.
114            app.failure_tracker.record_success(&entity_id);
115            Some(Ok(AuthResult {
116                identity: entity_id,
117                method: AuthMethod::Otp,
118                client_cert_der: None,
119                subject_dn: None,
120                subject_alt_names: Vec::new(),
121                extended_key_usage: Vec::new(),
122            }))
123        }
124        Err(e) => {
125            warn!(entity_id = %entity_id, error = %e, "OTP validation failed");
126
127            // Audit log the failed OTP attempt.
128            app.record_audit_event_with_actor(
129                "otp_auth_failure",
130                &entity_id,
131                &format!("entity_id={entity_id}, reason={e}"),
132            )
133            .await;
134
135            // FIA_AFL.1: record the failure and, if it reaches the threshold,
136            // emit the security-violation audit event and return the lockout
137            // response so the client learns it has been locked out.
138            // Only provisioned entities may consume bounded lockout state.
139            // Otherwise arbitrary usernames can exhaust tracking before any
140            // real account has failed authentication. The public failure stays
141            // generic; lookup failure must not admit an unverified identity.
142            let track = has_active_otp(app, &entity_id).await.unwrap_or(false);
143            let status = if track {
144                app.failure_tracker.record_failure(&entity_id)
145            } else {
146                LockoutStatus::Allowed
147            };
148            match status {
149                LockoutStatus::LockedOut { retry_after } => {
150                    warn!(entity_id = %entity_id, "OTP failure threshold reached: identity locked out");
151                    app.record_audit_event_with_actor(
152                        "auth_lockout",
153                        &entity_id,
154                        &format!(
155                            "entity_id={entity_id}, lockout_secs={}",
156                            retry_after.as_secs()
157                        ),
158                    )
159                    .await;
160                    Some(Err(locked_out_response(retry_after)))
161                }
162                LockoutStatus::Allowed => {
163                    Some(Err(unauthorized_response("OTP authentication failed")))
164                }
165            }
166        }
167    }
168}
169
170/// Admit failure tracking only for entities with an administratively provisioned,
171/// currently usable OTP. This lookup never exposes the token or its hash.
172async fn has_active_otp(app: &Arc<AppState>, entity_id: &str) -> Result<bool, sqlx::Error> {
173    let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
174    sqlx::query_scalar::<_, i64>(crate::db::pg_sql(
175        "SELECT id FROM otp_tokens WHERE entity_id = ? AND revoked = ? \
176         AND expires_at > ? AND current_uses < max_uses LIMIT 1",
177    ))
178    .bind(entity_id)
179    .bind(false)
180    .bind(now)
181    .fetch_optional(&app.db_ro)
182    .await
183    .map(|row| row.is_some())
184}
185
186/// Build a 429 Too Many Requests response with a `Retry-After` header
187/// (RFC 6585 §4 / RFC 9110 §10.2.3) for an identity that has been locked out
188/// under FIA_AFL.1.
189fn locked_out_response(retry_after: std::time::Duration) -> Response {
190    // Round up so the client never retries before the lockout truly expires.
191    let secs = retry_after.as_secs() + u64::from(retry_after.subsec_nanos() > 0);
192    let mut resp = (
193        StatusCode::TOO_MANY_REQUESTS,
194        "too many failed authentication attempts; account temporarily locked",
195    )
196        .into_response();
197    if let Ok(value) = HeaderValue::from_str(&secs.to_string()) {
198        resp.headers_mut().insert(RETRY_AFTER, value);
199    }
200    resp
201}
202
203/// Build a 401 Unauthorized response with the proper `WWW-Authenticate`
204/// header per RFC 7617 Section 2.2.
205///
206/// The challenge includes `charset="UTF-8"` to indicate that the server
207/// accepts UTF-8 encoded credentials (RFC 7617 Section 2.1).
208fn unauthorized_response(detail: &str) -> Response {
209    let mut resp = (StatusCode::UNAUTHORIZED, detail.to_string()).into_response();
210    resp.headers_mut().insert(
211        WWW_AUTHENTICATE,
212        HeaderValue::from_static(kipuka_util::WWW_AUTHENTICATE_BASIC),
213    );
214    resp
215}
216
217/// Row returned by the SELECT used for timing-safe OTP validation.
218#[derive(sqlx::FromRow)]
219struct OtpValidationRow {
220    id: i64,
221    token_hash: String,
222    current_uses: i64,
223    max_uses: i64,
224}
225
226/// Validate an OTP value against the configured backend.
227///
228/// Uses a two-phase approach that preserves both timing safety and
229/// atomicity:
230///
231/// 1. Hash the incoming OTP value with SHA-256.
232/// 2. SELECT the stored token_hash for this entity (read pool).
233/// 3. Constant-time compare the hashes in application code.
234/// 4. If match: atomic UPDATE (write pool) with token_hash in WHERE
235///    to consume the token — preventing TOCTOU races.
236/// 5. If no match: return error.
237///
238/// RHELBU-3536 R8: timing-safe comparison.
239/// RHELBU-3536 R9: OTP tokens are single-use by default.
240async fn validate_otp(app: &Arc<AppState>, entity_id: &str, otp_value: &str) -> Result<(), String> {
241    // Check that OTP authentication is enabled.
242    let otp_config = &app.config.otp;
243    if !otp_config.enabled {
244        return Err("OTP authentication is not enabled".into());
245    }
246
247    // Hash the incoming OTP value with SHA-256 (RHELBU-3536 R11).
248    let incoming_hash = hex::encode(Sha256::digest(otp_value.as_bytes()));
249
250    let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
251
252    // Phase 1: SELECT with token_hash in WHERE to find the exact matching
253    // token. The DB comparison is not timing-safe, but we verify with a
254    // constant-time compare as belt-and-suspenders (Phase 2).
255    let row: OtpValidationRow = sqlx::query_as(crate::db::pg_sql(
256        "SELECT id, token_hash, current_uses, max_uses FROM otp_tokens \
257         WHERE entity_id = ? AND token_hash = ? AND revoked = ? AND expires_at > ? AND current_uses < max_uses",
258    ))
259    .bind(entity_id)
260    .bind(&incoming_hash)
261    .bind(false)
262    .bind(&now)
263    .fetch_optional(&app.db_ro)
264    .await
265    .map_err(|e| format!("database error: {e}"))?
266    .ok_or_else(|| "no valid OTP found for this entity".to_string())?;
267
268    // Phase 2: Constant-time verification in application code (RHELBU-3536 R8).
269    // Belt-and-suspenders: even though the DB matched token_hash in WHERE,
270    // we verify here with a timing-safe comparison to prevent any DB-layer
271    // timing side-channel from leaking hash information.
272    if incoming_hash
273        .as_bytes()
274        .ct_eq(row.token_hash.as_bytes())
275        .unwrap_u8()
276        == 0
277    {
278        return Err("no valid OTP found for this entity".to_string());
279    }
280
281    // Phase 3: Atomic UPDATE on the write pool with token_hash in WHERE
282    // to consume the token — prevents TOCTOU races between SELECT and UPDATE.
283    let result = sqlx::query(crate::db::pg_sql(
284        "UPDATE otp_tokens SET current_uses = current_uses + 1 \
285         WHERE entity_id = ? AND token_hash = ? AND revoked = ? AND expires_at > ? AND current_uses < max_uses",
286    ))
287    .bind(entity_id)
288    .bind(&incoming_hash)
289    .bind(false)
290    .bind(&now)
291    .execute(&app.db)
292    .await
293    .map_err(|e| format!("database error: {e}"))?;
294
295    if result.rows_affected() == 0 {
296        return Err("OTP was consumed by a concurrent request".to_string());
297    }
298
299    debug!(
300        entity_id = %entity_id,
301        otp_id = row.id,
302        current_uses = row.current_uses + 1,
303        max_uses = row.max_uses,
304        "OTP validated and consumed"
305    );
306
307    Ok(())
308}