Skip to main content

kipuka/routes/admin/
otp.rs

1//! OTP management endpoints for the admin API.
2//!
3//! RHELBU-3536 R9: Administrators generate OTPs for EST enrollment
4//! via the admin API.  Each OTP is bound to an entity-id and has
5//! configurable expiry and usage limits.
6
7use std::sync::Arc;
8
9use axum::Json;
10use axum::extract::{Path, State};
11use axum::http::StatusCode;
12use axum::response::{IntoResponse, Response};
13use base64::Engine;
14use base64::engine::general_purpose::URL_SAFE_NO_PAD;
15use rand::TryRng;
16use rand::rngs::SysRng;
17use serde::{Deserialize, Serialize};
18use sha2::{Digest, Sha256};
19
20use super::AdminAuth;
21use crate::state::AppState;
22
23/// Request body for `POST /admin/otp/generate`.
24#[derive(Deserialize)]
25pub struct GenerateOtpRequest {
26    /// The entity identifier for which the OTP is valid.
27    ///
28    /// This is the device or service name that will present the OTP
29    /// in the HTTP Basic `username` field during EST enrollment.
30    pub entity_id: String,
31
32    /// Optional override for OTP expiry (seconds from creation).
33    /// Uses the global `[otp].ttl_seconds` when absent.
34    pub ttl_seconds: Option<u64>,
35
36    /// Optional override for maximum OTP usage count.
37    /// Uses the global `[otp].max_usage` when absent.
38    pub max_usage: Option<u32>,
39}
40
41/// Response for a successfully generated OTP.
42#[derive(Serialize)]
43pub struct OtpResponse {
44    /// The generated OTP token value.
45    ///
46    /// This value is shown exactly once — it is not recoverable after
47    /// this response.
48    pub token: String,
49
50    /// The entity identifier this OTP is bound to.
51    pub entity_id: String,
52
53    /// When the OTP expires (RFC 3339 timestamp).
54    pub expires_at: String,
55
56    /// Maximum number of times this OTP can be used.
57    pub max_usage: u32,
58}
59
60/// OTP summary for listing.
61#[derive(Serialize)]
62pub struct OtpSummary {
63    /// Opaque OTP identifier for management (not the OTP value).
64    pub id: String,
65
66    /// The entity identifier this OTP is bound to.
67    pub entity_id: String,
68
69    /// When the OTP expires (RFC 3339 timestamp).
70    pub expires_at: String,
71
72    /// Maximum allowed uses.
73    pub max_usage: u32,
74
75    /// How many times the OTP has been used so far.
76    pub usage_count: u32,
77
78    /// When the OTP was created (RFC 3339 timestamp).
79    pub created_at: String,
80}
81
82/// `POST /admin/otp/generate` — Generate a new OTP.
83///
84/// RHELBU-3536 R9: Creates a new one-time password bound to the
85/// specified entity identifier.
86///
87/// # Request
88///
89/// ```json
90/// {
91///   "entity_id": "device-001.example.com",
92///   "ttl_seconds": 7200,
93///   "max_usage": 1
94/// }
95/// ```
96///
97/// # Response
98///
99/// ```json
100/// {
101///   "token": "kE9x...",
102///   "entity_id": "device-001.example.com",
103///   "expires_at": "2026-06-22T20:00:00Z",
104///   "max_usage": 1
105/// }
106/// ```
107///
108/// The `token` field contains the actual OTP value.  It is returned
109/// exactly once and cannot be retrieved later.
110pub async fn generate_otp(
111    admin: AdminAuth,
112    State(state): State<Arc<AppState>>,
113    Json(req): Json<GenerateOtpRequest>,
114) -> Response {
115    // FMT_SMR.1: only operators may issue OTPs.
116    if let Some(resp) = admin.require_operator() {
117        return resp;
118    }
119
120    let otp_config = &state.config.otp;
121
122    // Check that OTP is enabled.
123    if !otp_config.enabled {
124        return (
125            StatusCode::BAD_REQUEST,
126            Json(serde_json::json!({
127                "error": "otp_disabled",
128                "detail": "OTP authentication is not enabled in server configuration"
129            })),
130        )
131            .into_response();
132    }
133
134    if req.entity_id.is_empty() {
135        return (
136            StatusCode::BAD_REQUEST,
137            Json(serde_json::json!({
138                "error": "invalid_entity_id",
139                "detail": "entity_id must not be empty"
140            })),
141        )
142            .into_response();
143    }
144
145    let ttl = req.ttl_seconds.unwrap_or(otp_config.ttl_seconds);
146    let max_usage = req.max_usage.unwrap_or(otp_config.max_usage);
147
148    // Generate the OTP token with configured entropy.
149    let entropy_bytes = (otp_config.entropy_bits / 8) as usize;
150    let mut raw = vec![0u8; entropy_bytes];
151    // SysRng draws from the OS CSPRNG (getrandom); fallible because OS entropy
152    // retrieval can fail. Fail closed with a 500 rather than issue a token
153    // backed by low or zero entropy.
154    if let Err(e) = SysRng.try_fill_bytes(&mut raw) {
155        tracing::error!(error = %e, "OS RNG failed while generating OTP token");
156        return (
157            StatusCode::INTERNAL_SERVER_ERROR,
158            Json(serde_json::json!({
159                "error": "rng_failure",
160                "detail": "failed to obtain secure random bytes for OTP generation"
161            })),
162        )
163            .into_response();
164    }
165    let token = URL_SAFE_NO_PAD.encode(&raw);
166
167    // Hash the token with SHA-256 before storing (RHELBU-3536 R11).
168    let token_hash = hex::encode(Sha256::digest(token.as_bytes()));
169
170    let now = chrono::Utc::now();
171    let expires_at = (now + chrono::Duration::seconds(ttl as i64))
172        .to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
173
174    // Insert the hashed token into the database.
175    let insert_result = sqlx::query(crate::db::pg_sql(
176        "INSERT INTO otp_tokens (token_hash, entity_id, current_uses, max_uses, expires_at) \
177         VALUES (?, ?, 0, ?, ?)",
178    ))
179    .bind(&token_hash)
180    .bind(&req.entity_id)
181    .bind(max_usage as i64)
182    .bind(&expires_at)
183    .execute(&state.db)
184    .await;
185
186    if let Err(e) = insert_result {
187        tracing::error!(error = %e, "failed to store OTP token");
188        return (
189            StatusCode::INTERNAL_SERVER_ERROR,
190            Json(serde_json::json!({
191                "error": "storage_error",
192                "detail": "Failed to store OTP token"
193            })),
194        )
195            .into_response();
196    }
197
198    // Audit log the OTP generation.
199    state
200        .record_audit_event("otp_generated", &format!("entity_id={}", req.entity_id))
201        .await;
202
203    (
204        StatusCode::CREATED,
205        Json(OtpResponse {
206            token,
207            entity_id: req.entity_id,
208            expires_at,
209            max_usage,
210        }),
211    )
212        .into_response()
213}
214
215/// `GET /admin/otp` — List active OTPs.
216///
217/// Returns all non-expired, non-fully-consumed OTPs.  The actual OTP
218/// token values are NOT included (they are one-time secrets shown only
219/// at generation time).
220pub async fn list_otps(_admin: AdminAuth, State(state): State<Arc<AppState>>) -> Response {
221    if !state.config.otp.enabled {
222        return (
223            StatusCode::BAD_REQUEST,
224            Json(serde_json::json!({
225                "error": "otp_disabled",
226                "detail": "OTP authentication is not enabled"
227            })),
228        )
229            .into_response();
230    }
231
232    let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
233
234    let rows: Vec<OtpRow> = match sqlx::query_as(crate::db::pg_sql(
235        "SELECT id, entity_id, expires_at, max_uses, current_uses, created_at \
236         FROM otp_tokens WHERE revoked = ? AND expires_at > ?",
237    ))
238    .bind(false)
239    .bind(&now)
240    .fetch_all(&state.db_ro)
241    .await
242    {
243        Ok(rows) => rows,
244        Err(e) => {
245            tracing::error!(error = %e, "failed to list OTP tokens");
246            return (
247                StatusCode::INTERNAL_SERVER_ERROR,
248                Json(serde_json::json!({
249                    "error": "storage_error",
250                    "detail": "Failed to query OTP tokens"
251                })),
252            )
253                .into_response();
254        }
255    };
256
257    let otps: Vec<OtpSummary> = rows
258        .into_iter()
259        .map(|r| OtpSummary {
260            id: r.id.to_string(),
261            entity_id: r.entity_id.unwrap_or_default(),
262            expires_at: r.expires_at,
263            max_usage: r.max_uses as u32,
264            usage_count: r.current_uses as u32,
265            created_at: r.created_at,
266        })
267        .collect();
268
269    (StatusCode::OK, Json(otps)).into_response()
270}
271
272/// `DELETE /admin/otp/{id}` — Revoke an OTP.
273///
274/// Immediately invalidates the specified OTP, preventing any further
275/// enrollment attempts using it.
276pub async fn revoke_otp(
277    admin: AdminAuth,
278    Path(id): Path<String>,
279    State(state): State<Arc<AppState>>,
280) -> Response {
281    // FMT_SMR.1: only operators may revoke OTPs.
282    if let Some(resp) = admin.require_operator() {
283        return resp;
284    }
285
286    if !state.config.otp.enabled {
287        return (
288            StatusCode::BAD_REQUEST,
289            Json(serde_json::json!({
290                "error": "otp_disabled",
291                "detail": "OTP authentication is not enabled"
292            })),
293        )
294            .into_response();
295    }
296
297    // Parse the ID as an integer for the database lookup.
298    let otp_id: i64 = match id.parse() {
299        Ok(v) => v,
300        Err(_) => {
301            return (
302                StatusCode::BAD_REQUEST,
303                Json(serde_json::json!({
304                    "error": "invalid_id",
305                    "detail": "OTP id must be a valid integer"
306                })),
307            )
308                .into_response();
309        }
310    };
311
312    let result = sqlx::query(crate::db::pg_sql(
313        "UPDATE otp_tokens SET revoked = ?, revoked_at = ? WHERE id = ?",
314    ))
315    .bind(true)
316    .bind(chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true))
317    .bind(otp_id)
318    .execute(&state.db)
319    .await;
320
321    match result {
322        Ok(r) if r.rows_affected() == 0 => {
323            return (
324                StatusCode::NOT_FOUND,
325                Json(serde_json::json!({
326                    "error": "not_found",
327                    "detail": "OTP not found"
328                })),
329            )
330                .into_response();
331        }
332        Err(e) => {
333            tracing::error!(error = %e, "failed to revoke OTP");
334            return (
335                StatusCode::INTERNAL_SERVER_ERROR,
336                Json(serde_json::json!({
337                    "error": "storage_error",
338                    "detail": "Failed to revoke OTP"
339                })),
340            )
341                .into_response();
342        }
343        _ => {}
344    }
345
346    state
347        .record_audit_event("otp_revoked", &format!("otp_id={id}"))
348        .await;
349
350    tracing::info!(otp_id = %id, "OTP revoked");
351
352    StatusCode::NO_CONTENT.into_response()
353}
354
355/// Internal row type for reading OTP records from the database.
356#[derive(sqlx::FromRow)]
357struct OtpRow {
358    id: i64,
359    entity_id: Option<String>,
360    expires_at: String,
361    max_uses: i64,
362    current_uses: i64,
363    created_at: String,
364}