1use std::sync::Arc;
13
14use kipuka_coap::CoapError;
15use kipuka_coap::dtls::ClientCertInfo;
16use kipuka_coap::server::{AuditInfo, EstOperation, EstResponse};
17
18use crate::error::KipukaError;
19use crate::routes::LabelExtractor;
20use crate::state::AppState;
21
22pub struct CoapEstHandler {
29 state: Arc<AppState>,
30}
31
32impl CoapEstHandler {
33 pub fn new(state: Arc<AppState>) -> Self {
35 Self { state }
36 }
37}
38
39impl kipuka_coap::EstHandler for CoapEstHandler {
40 fn handle(
41 &self,
42 operation: EstOperation,
43 label: Option<&str>,
44 payload: &[u8],
45 _content_format: Option<u16>,
46 client_cert: Option<&ClientCertInfo>,
47 ) -> Result<EstResponse, CoapError> {
48 if let Some(name) = label.filter(|name| !name.is_empty())
50 && !self
51 .state
52 .config
53 .est
54 .labels
55 .iter()
56 .any(|entry| entry.name == name)
57 {
58 return Err(CoapError::ResourceNotFound(format!(
59 "unknown EST label: {name}"
60 )));
61 }
62 if matches!(
63 operation,
64 EstOperation::SimpleEnroll | EstOperation::SimpleReenroll | EstOperation::ServerKeygen
65 ) {
66 let cert = client_cert.ok_or_else(|| {
67 CoapError::Forbidden("authenticated DTLS client certificate required".into())
68 })?;
69 let label_config = self
70 .state
71 .config
72 .est
73 .labels
74 .iter()
75 .find(|c| Some(c.name.as_str()) == label);
76 if label_config.is_some_and(|c| {
77 !c.auth_methods.is_empty()
78 && !c.auth_methods.contains(&crate::config::EstAuthMethod::Mtls)
79 && !c
80 .auth_methods
81 .contains(&crate::config::EstAuthMethod::Certificate)
82 }) {
83 return Err(CoapError::Forbidden("mTLS not permitted for label".into()));
84 }
85 let runtime = tokio::runtime::Handle::try_current()
86 .map_err(|_| CoapError::Internal("CoAP runtime unavailable".into()))?;
87 runtime.block_on(async {
88 crate::auth::mtls::check_revocation(&cert.der_bytes, &self.state)
89 .await
90 .map_err(CoapError::Forbidden)?;
91 self.state
92 .admit_enrollment(&cert.subject_dn, "coap")
93 .await
94 .map_err(|e| CoapError::Internal(e.to_string()))
95 })?;
96 }
97 let result = match operation {
98 EstOperation::CaCerts => handle_cacerts(label, &self.state),
99 EstOperation::SimpleEnroll => {
100 handle_simpleenroll(payload, label, client_cert, &self.state)
101 }
102 EstOperation::SimpleReenroll => {
103 handle_simplereenroll(payload, label, client_cert, &self.state)
104 }
105 EstOperation::CsrAttrs => handle_csrattrs(label, &self.state),
106 EstOperation::ServerKeygen => Err(CoapError::Internal(
107 "server key generation not yet implemented for CoAP transport".into(),
108 )),
109 };
110
111 let actor = client_cert
118 .map(|c| c.subject_dn.clone())
119 .unwrap_or_default();
120 match &result {
121 Ok(resp) => {
122 if let Some(audit) = resp.audit_event.as_ref() {
123 self.spawn_audit(audit.event_type.clone(), actor, audit.detail.clone());
124 }
125 }
126 Err(err) => {
127 if let Some((event_type, detail)) = coap_denial_audit(operation, err) {
128 self.spawn_audit(event_type, actor, detail);
129 }
130 }
131 }
132
133 result
134 }
135}
136
137impl CoapEstHandler {
138 fn spawn_audit(&self, event_type: String, actor: String, detail: String) {
146 let state = Arc::clone(&self.state);
147 match tokio::runtime::Handle::try_current() {
148 Ok(handle) => {
149 handle.spawn(async move {
150 if actor.is_empty() {
151 state.record_audit_event(&event_type, &detail).await;
152 } else {
153 state
154 .record_audit_event_with_actor(&event_type, &actor, &detail)
155 .await;
156 }
157 });
158 }
159 Err(_) => {
160 tracing::warn!(
161 %event_type,
162 "no Tokio runtime available; CoAP audit event dropped"
163 );
164 }
165 }
166 }
167}
168
169fn coap_denial_audit(operation: EstOperation, err: &CoapError) -> Option<(String, String)> {
185 let event_type = match operation {
186 EstOperation::SimpleEnroll => "coap_simpleenroll_denied",
187 EstOperation::SimpleReenroll => "coap_simplereenroll_denied",
188 _ => return None,
189 };
190 match err {
191 CoapError::Forbidden(msg) | CoapError::InvalidMessage(msg) => {
192 Some((event_type.to_string(), msg.clone()))
193 }
194 _ => None,
195 }
196}
197
198fn resolve_label(state: &Arc<AppState>, label: Option<&str>) -> Result<LabelExtractor, CoapError> {
205 LabelExtractor::resolve(state, label).map_err(|e| match e {
206 KipukaError::NotFound => {
208 CoapError::ResourceNotFound(format!("unknown EST label: {label:?}"))
209 }
210 other => CoapError::Internal(format!("label resolution failed: {other}")),
212 })
213}
214
215fn handle_cacerts(label: Option<&str>, state: &Arc<AppState>) -> Result<EstResponse, CoapError> {
222 let label_ex = resolve_label(state, label)?;
225 let ca = state
226 .get_ca(label_ex.ca_id())
227 .ok_or_else(|| CoapError::Internal(format!("CA not found for id={}", label_ex.ca_id())))?;
228
229 let pkcs7_der =
230 crate::routes::cacerts::build_certs_only_pkcs7(std::slice::from_ref(&ca.cert_der))
231 .map_err(|e| CoapError::Internal(format!("PKCS#7 build failed: {e}")))?;
232
233 tracing::debug!(ca_id = %ca.id, "CoAP /cacerts served");
234
235 Ok(EstResponse {
236 payload: pkcs7_der,
237 content_format: kipuka_coap::content_format::APPLICATION_PKCS7_MIME_CERTS_ONLY,
238 audit_event: Some(AuditInfo {
239 event_type: "coap_cacerts".into(),
240 detail: format!("ca_id={}", ca.id),
241 }),
242 })
243}
244
245fn handle_simpleenroll(
254 csr_der: &[u8],
255 label: Option<&str>,
256 client_cert: Option<&ClientCertInfo>,
257 state: &Arc<AppState>,
258) -> Result<EstResponse, CoapError> {
259 if csr_der.is_empty() {
260 return Err(CoapError::InvalidMessage("empty CSR payload".into()));
261 }
262
263 let label_ex = resolve_label(state, label)?;
267 let ca_id = label_ex.ca_id().to_string();
268
269 let identity = client_cert.map(|c| c.subject_dn.as_str()).unwrap_or("");
279 if let Err(reason) =
280 crate::auth::enroll_authz::authorize_csr_der(csr_der, identity, &label_ex.enroll_policy())
281 {
282 tracing::warn!(
283 ca_id = %ca_id,
284 identity = %identity,
285 %reason,
286 "coap simpleenroll rejected: CSR not authorized for requester"
287 );
288 return Err(CoapError::Forbidden(format!(
289 "enrollment not authorized: {reason}"
290 )));
291 }
292
293 let ca = state
294 .get_ca(&ca_id)
295 .ok_or_else(|| CoapError::Internal(format!("CA not found for id={ca_id}")))?;
296
297 let ca_cfg = state
299 .config
300 .cas
301 .iter()
302 .find(|c| c.id == ca_id)
303 .ok_or_else(|| CoapError::Internal(format!("CA config not found for id={ca_id}")))?;
304
305 let resolved_key = crate::ca::issue::resolve_signing_key_sync(ca_cfg, state.hsm.as_ref())
307 .map_err(|e| CoapError::Internal(format!("signing key resolution failed: {e}")))?;
308
309 let profile = crate::ca::issue::EnrollmentProfile {
311 max_validity_days: ca
312 .validity_days
313 .min(crate::ca::issue::cab_forum_max_validity_days()),
314 ..crate::ca::issue::EnrollmentProfile::default()
315 };
316
317 let result = crate::ca::issue::issue_certificate(
319 csr_der,
320 &profile,
321 &ca.cert_der,
322 resolved_key.as_signing_key(),
323 &ca.hash_algorithm,
324 ca.ocsp_url.as_deref(),
325 ca.crl_url.as_deref(),
326 )
327 .map_err(|e| {
328 let msg = format!("certificate issuance failed: {e}");
333 if e.is_client_error() {
334 CoapError::InvalidMessage(msg)
335 } else {
336 CoapError::Internal(msg)
337 }
338 })?;
339
340 tracing::info!(
341 ca_id = %ca_id,
342 serial = %result.serial_number,
343 subject = %result.subject_dn,
344 "CoAP simpleenroll: certificate issued"
345 );
346
347 tokio::runtime::Handle::try_current()
348 .map_err(|_| CoapError::Internal("CoAP runtime unavailable".into()))?
349 .block_on(crate::ca::issue::persist_certificate(
350 state,
351 &ca_id,
352 &profile.name,
353 &result,
354 ))
355 .map_err(|e| CoapError::Internal(e.to_string()))?;
356
357 let pkcs7_der = crate::routes::cacerts::build_certs_only_pkcs7(std::slice::from_ref(
359 &result.certificate_der,
360 ))
361 .map_err(|e| CoapError::Internal(format!("PKCS#7 wrap failed: {e}")))?;
362
363 Ok(EstResponse {
364 payload: pkcs7_der,
365 content_format: kipuka_coap::content_format::APPLICATION_PKCS7_MIME_CERTS_ONLY,
366 audit_event: Some(AuditInfo {
367 event_type: "coap_simpleenroll".into(),
368 detail: format!(
369 "serial={} subject={}",
370 result.serial_number, result.subject_dn
371 ),
372 }),
373 })
374}
375
376fn handle_simplereenroll(
381 csr_der: &[u8],
382 label: Option<&str>,
383 client_cert: Option<&ClientCertInfo>,
384 state: &Arc<AppState>,
385) -> Result<EstResponse, CoapError> {
386 let _cert = client_cert.ok_or_else(|| {
388 CoapError::Unauthorized(
389 "simplereenroll requires DTLS client certificate authentication".into(),
390 )
391 })?;
392
393 let mut resp = handle_simpleenroll(csr_der, label, client_cert, state)?;
397
398 if let Some(ref mut audit) = resp.audit_event {
400 audit.event_type = "coap_simplereenroll".into();
401 }
402
403 Ok(resp)
404}
405
406fn handle_csrattrs(label: Option<&str>, state: &Arc<AppState>) -> Result<EstResponse, CoapError> {
411 let label_ex = resolve_label(state, label)?;
417
418 let attributes = if label_ex.csr_attributes.is_empty() {
420 &state.config.est.csr_attributes
421 } else {
422 &label_ex.csr_attributes
423 };
424
425 let template = label_ex
427 .csr_template
428 .as_ref()
429 .or(state.config.est.csr_template.as_ref());
430
431 let csrattrs_der =
432 crate::routes::csrattrs::encode_csr_attrs_with_template(attributes, template)
433 .map_err(|e| CoapError::Internal(format!("CSR attributes encoding failed: {e}")))?;
434
435 let detail = if csrattrs_der.is_empty() {
436 "empty attributes".to_string()
437 } else {
438 format!("{} attributes", attributes.len())
439 };
440
441 Ok(EstResponse {
442 payload: csrattrs_der,
443 content_format: kipuka_coap::content_format::APPLICATION_CSRATTRS,
444 audit_event: Some(AuditInfo {
445 event_type: "coap_csrattrs".into(),
446 detail,
447 }),
448 })
449}