Skip to main content

kipuka/routes/
coap.rs

1//! EST-over-CoAP bridge (RFC 9483).
2//!
3//! This module implements the [`kipuka_coap::EstHandler`] trait, bridging
4//! parsed CoAP EST requests to the shared EST enrollment logic.  It lives
5//! in the main crate (not in `kipuka-coap`) so that it can access
6//! [`AppState`], CA signing functions, and the database.
7//!
8//! The handler is synchronous (matching the `EstHandler` trait contract),
9//! using [`crate::ca::issue::resolve_signing_key_sync`] for key material
10//! and [`crate::ca::issue::issue_certificate`] for certificate issuance.
11
12use 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
22/// EST handler implementation that bridges CoAP requests to shared
23/// enrollment logic.
24///
25/// Constructed with a reference to the application state and passed
26/// to [`CoapDtlsServer::run()`](kipuka_coap::CoapDtlsServer::run)
27/// at startup.
28pub struct CoapEstHandler {
29    state: Arc<AppState>,
30}
31
32impl CoapEstHandler {
33    /// Create a new CoAP EST handler wrapping the application state.
34    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        // Reject unknown names before authentication without selecting a CA twice.
49        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        // NIAP FAU_GEN.1: persist an audit event for every CoAP EST operation,
112        // on success and on authorization denial alike.  The `kipuka-coap`
113        // transport layer has no `AppState` access, so audit persistence must
114        // happen here — the one dispatch point that holds it.  Previously CoAP
115        // events were only logged by the server loop and never written to the
116        // `audit_events` table, leaving the entire transport un-audited.
117        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    /// Persist a CoAP EST audit event (NIAP FAU_GEN.1) from the synchronous
139    /// [`kipuka_coap::EstHandler`] context.
140    ///
141    /// `record_audit_event*` is async but the trait method is synchronous, so
142    /// the write is spawned onto the current Tokio runtime (the CoAP server
143    /// loop that invoked us).  When an authenticated DTLS identity is present it
144    /// is recorded as the actor, so the FAU_SAR.1 review filter is populated.
145    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
169/// Map a failed CoAP EST operation to its audit event, when the failure is a
170/// security-relevant enrollment rejection (FDP_ACF.1 / RFC 7030 §4.2 →
171/// FAU_GEN.1).
172///
173/// Two failure classes produce an `*_denied` audit event, matching the HTTP
174/// taxonomy:
175/// - `Forbidden` — an authorization denial (the requester is not permitted to
176///   enroll the requested identity).
177/// - `InvalidMessage` — a rejected client CSR (empty payload, or a bad
178///   proof-of-possession / undersized key / disallowed algorithm classified by
179///   [`IssuanceError::is_client_error`](crate::ca::issue::IssuanceError::is_client_error)).
180///
181/// Purely operational faults (`Internal`, DTLS, block-transfer) are surfaced to
182/// the client and logged but are not enrollment decisions, so they are not
183/// audited here.
184fn 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
198/// Resolve an EST label into its enrollment configuration for the CoAP
199/// transport, mapping resolution failures onto CoAP error codes.
200///
201/// Shares [`LabelExtractor::resolve`] with the HTTP/CMS-EST extractor so that
202/// the per-label CA selection and FDP_ACF.1 access-control policy are
203/// identical across transports.
204fn resolve_label(state: &Arc<AppState>, label: Option<&str>) -> Result<LabelExtractor, CoapError> {
205    LabelExtractor::resolve(state, label).map_err(|e| match e {
206        // An unknown label is a client addressing error → 4.04 Not Found.
207        KipukaError::NotFound => {
208            CoapError::ResourceNotFound(format!("unknown EST label: {label:?}"))
209        }
210        // A misconfigured label (dangling CA reference) is a server fault.
211        other => CoapError::Internal(format!("label resolution failed: {other}")),
212    })
213}
214
215/// Handle GET /cacerts — return the CA certificate chain as PKCS#7 certs-only.
216///
217/// RFC 9483 §5.1: The response Content-Format is 281
218/// (`application/pkcs7-mime; smime-type=certs-only`).
219///
220/// Unlike the HTTP handler, the CoAP response is raw DER (not base64).
221fn handle_cacerts(label: Option<&str>, state: &Arc<AppState>) -> Result<EstResponse, CoapError> {
222    // Honour the label's CA selection so `/cacerts` returns the chain that
223    // matches the CA a subsequent enrollment on the same label would use.
224    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
245/// Handle POST /simpleenroll — issue a certificate from a PKCS#10 CSR.
246///
247/// RFC 9483 §5.3: The request Content-Format is 285 (`application/pkcs10`),
248/// carrying the DER-encoded CSR directly (no base64 wrapping).
249///
250/// Uses the synchronous key resolution path
251/// ([`crate::ca::issue::resolve_signing_key_sync`]) since the `EstHandler`
252/// trait is synchronous.
253fn 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    // Resolve the label to its CA and per-label access-control policy.  Over
264    // CoAP the label is the only carrier of an FDP_ACF.1 policy, so this is
265    // where transport parity with HTTP is established.
266    let label_ex = resolve_label(state, label)?;
267    let ca_id = label_ex.ca_id().to_string();
268
269    // Enrollment authorization (NIAP CA PP FDP_ACF.1).
270    //
271    // Enforce the same identity-binding and name-allowlist policy the HTTP
272    // `/simpleenroll` path applies, keyed off the authenticated DTLS client
273    // identity (its subject DN) when one was presented.  A no-op unless the
274    // resolved label opts in, so unlabeled/default enrollment is unchanged.
275    // Without this check the CoAP transport issued certificates for any names
276    // the requester chose to put in the CSR — a silent bypass of the policy
277    // enforced on every other transport.
278    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    // Find the CA config entry.
298    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    // Resolve the signing key synchronously (filesystem read or HSM lookup).
306    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    // Build a default enrollment profile.
310    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    // Issue the certificate using the shared issuance logic.
318    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        // A bad client CSR (e.g. failed proof-of-possession) maps to 4.00 Bad
329        // Request — the dispatch renders CoapError::InvalidMessage as
330        // CoapCode::BAD_REQUEST — while a server-side fault stays 5.00 Internal.
331        // The dispatch's coap_denial_audit records the client-caused rejection.
332        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    // Wrap the issued certificate in PKCS#7 certs-only (reuses cacerts builder).
358    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
376/// Handle POST /simplereenroll — re-enroll using existing DTLS client certificate.
377///
378/// RFC 9483 §5.3: For re-enrollment, the client authenticates using its
379/// existing certificate via DTLS client auth, and submits a new CSR.
380fn handle_simplereenroll(
381    csr_der: &[u8],
382    label: Option<&str>,
383    client_cert: Option<&ClientCertInfo>,
384    state: &Arc<AppState>,
385) -> Result<EstResponse, CoapError> {
386    // Re-enrollment requires a client certificate from the DTLS handshake.
387    let _cert = client_cert.ok_or_else(|| {
388        CoapError::Unauthorized(
389            "simplereenroll requires DTLS client certificate authentication".into(),
390        )
391    })?;
392
393    // The issuance logic is shared with simpleenroll, including the FDP_ACF.1
394    // authorization check — the client cert is passed through so the policy is
395    // keyed off the authenticated re-enrolling identity.
396    let mut resp = handle_simpleenroll(csr_der, label, client_cert, state)?;
397
398    // Override the audit event type to distinguish re-enrollment.
399    if let Some(ref mut audit) = resp.audit_event {
400        audit.event_type = "coap_simplereenroll".into();
401    }
402
403    Ok(resp)
404}
405
406/// Handle GET /csrattrs — return CSR attributes the server expects.
407///
408/// RFC 9483 §5.1: The response Content-Format is 287
409/// (`application/csrattrs`).
410fn handle_csrattrs(label: Option<&str>, state: &Arc<AppState>) -> Result<EstResponse, CoapError> {
411    // Resolve the label so per-label CSR attributes and the RFC 9908 template
412    // are honoured, matching the HTTP `/csrattrs` handler (transport parity).
413    // An unknown label is a client addressing error → 4.04 Not Found, exactly
414    // as for `/cacerts`.  Previously this read the global attribute list
415    // directly, silently ignoring per-label overrides.
416    let label_ex = resolve_label(state, label)?;
417
418    // Per-label attributes override the global list when non-empty.
419    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    // Per-label template overrides the global template.
426    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}