kipuka/routes/
simpleenroll.rs1use std::sync::Arc;
11
12use axum::body::Bytes;
13use axum::extract::State;
14use axum::http::{HeaderValue, StatusCode, header};
15use axum::response::{IntoResponse, Response};
16
17use crate::auth::EstAuth;
18use crate::error::KipukaError;
19use crate::routes::LabelExtractor;
20use crate::routes::est::{content_types, decode_est_base64, encode_est_base64};
21use crate::state::AppState;
22
23pub async fn post_simpleenroll(
57 auth: EstAuth,
58 label: LabelExtractor,
59 State(state): State<Arc<AppState>>,
60 body: Bytes,
61) -> Result<Response, KipukaError> {
62 let ca_id = label.ca_id();
63 let identity = &auth.0.identity;
64
65 tracing::info!(
66 ca_id = %ca_id,
67 label = %label.label,
68 identity = %identity,
69 method = ?auth.0.method,
70 "simpleenroll request"
71 );
72
73 let csr_der = decode_est_base64(&body)
75 .map_err(|e| KipukaError::BadRequest(format!("CSR decoding failed: {e}")))?;
76
77 validate_csr(&csr_der, &auth.0, &label)?;
79
80 let disconnected = label.disconnected.unwrap_or(state.config.est.disconnected);
82
83 if disconnected {
84 tracing::info!(
86 ca_id = %ca_id,
87 identity = %identity,
88 "disconnected mode: queuing CSR for deferred signing"
89 );
90
91 let retry_after = state.config.est.disconnected_retry_after_secs;
95
96 let mut resp = StatusCode::ACCEPTED.into_response();
97 if let Ok(hv) = HeaderValue::from_str(&retry_after.to_string()) {
98 resp.headers_mut().insert(header::RETRY_AFTER, hv);
99 }
100
101 state
102 .record_audit_event(
103 "simpleenroll_deferred",
104 &format!("ca_id={ca_id}, identity={identity}"),
105 )
106 .await;
107
108 return Ok(resp);
109 }
110
111 let ca = state.get_ca(ca_id).ok_or(KipukaError::NotFound)?;
113
114 let ca_cfg = state
116 .config
117 .cas
118 .iter()
119 .find(|c| c.id == ca_id)
120 .ok_or_else(|| KipukaError::Ca(format!("CA config not found for id={ca_id}")))?;
121
122 let ca_key_pem: Vec<u8>;
124 let key_label_owned: String;
125
126 let signing_key = if ca_cfg.is_hsm_backed() {
127 let hsm_ctx = state
128 .hsm
129 .as_ref()
130 .ok_or_else(|| KipukaError::Ca("HSM not configured but CA has pkcs11_uri".into()))?;
131 key_label_owned = parse_pkcs11_object_label(ca_cfg.pkcs11_uri.as_deref().unwrap())
132 .map_err(|e| KipukaError::Ca(format!("invalid pkcs11_uri: {e}")))?;
133 crate::ca::issue::CaSigningKey::Hsm {
134 context: hsm_ctx,
135 key_label: &key_label_owned,
136 }
137 } else {
138 ca_key_pem = tokio::fs::read(&ca_cfg.key_file).await.map_err(|e| {
139 KipukaError::Ca(format!("failed to read CA key {}: {e}", ca_cfg.key_file))
140 })?;
141 crate::ca::issue::CaSigningKey::Pem(&ca_key_pem)
142 };
143
144 let profile = crate::ca::issue::EnrollmentProfile {
147 max_validity_days: ca.validity_days.min(398),
148 ..crate::ca::issue::EnrollmentProfile::default()
149 };
150
151 let result = crate::ca::issue::issue_certificate(
153 &csr_der,
154 &profile,
155 &ca.cert_der,
156 signing_key,
157 &ca.hash_algorithm,
158 )
159 .map_err(|e| KipukaError::Ca(format!("certificate issuance failed: {e}")))?;
160
161 let serial = &result.serial_number;
163 let subject_dn = &result.subject_dn;
164 let issuer_dn = synta_certificate::format_dn(
165 &synta_certificate::Certificate::from_der(&ca.cert_der)
166 .map(|c| c.tbs_certificate.subject.0.to_vec())
167 .unwrap_or_default(),
168 );
169 let not_before_str = result.not_before.format("%Y-%m-%dT%H:%M:%SZ").to_string();
170 let not_after_str = result.not_after.format("%Y-%m-%dT%H:%M:%SZ").to_string();
171
172 if let Err(e) = sqlx::query(crate::db::pg_sql(
173 "INSERT INTO certificates (serial, subject_dn, issuer_dn, not_before, not_after, der_encoded, ca_id, profile, status) \
174 VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active')",
175 ))
176 .bind(serial)
177 .bind(subject_dn)
178 .bind(&issuer_dn)
179 .bind(¬_before_str)
180 .bind(¬_after_str)
181 .bind(&result.certificate_der)
182 .bind(ca_id)
183 .bind(&profile.name)
184 .execute(&state.db)
185 .await
186 {
187 tracing::error!(error = %e, serial = %serial, "failed to store issued certificate in DB");
189 }
190
191 let cert_der = result.certificate_der;
192
193 let pkcs7_der = cert_der;
197
198 let body = encode_est_base64(&pkcs7_der);
199
200 let mut resp = (StatusCode::OK, body).into_response();
201 resp.headers_mut().insert(
202 header::CONTENT_TYPE,
203 HeaderValue::from_static(content_types::PKCS7_CERTS),
204 );
205 resp.headers_mut().insert(
206 header::HeaderName::from_static("content-transfer-encoding"),
207 HeaderValue::from_static(content_types::TRANSFER_ENCODING_BASE64),
208 );
209
210 state
211 .record_audit_event(
212 "simpleenroll_success",
213 &format!("ca_id={ca_id}, identity={identity}"),
214 )
215 .await;
216
217 Ok(resp)
218}
219
220fn validate_csr(
238 csr_der: &[u8],
239 _auth: &crate::auth::AuthResult,
240 _label: &LabelExtractor,
241) -> Result<(), KipukaError> {
242 if csr_der.is_empty() {
243 return Err(KipukaError::BadRequest("empty CSR".into()));
244 }
245
246 if csr_der.len() < 60 {
274 return Err(KipukaError::BadRequest(
275 "CSR is too short to be valid".into(),
276 ));
277 }
278
279 Ok(())
280}
281
282pub fn parse_pkcs11_object_label(uri: &str) -> Result<String, String> {
292 let path = uri
294 .strip_prefix("pkcs11:")
295 .ok_or_else(|| format!("not a pkcs11: URI: {uri}"))?;
296
297 for part in path.split(';') {
299 if let Some((key, value)) = part.split_once('=')
300 && key == "object"
301 {
302 return pkcs11_percent_decode(value);
303 }
304 }
305
306 Err(format!("pkcs11 URI missing 'object' attribute: {uri}"))
307}
308
309fn pkcs11_percent_decode(s: &str) -> Result<String, String> {
311 let mut result = Vec::with_capacity(s.len());
312 let bytes = s.as_bytes();
313 let mut i = 0;
314 while i < bytes.len() {
315 if bytes[i] == b'%' && i + 2 < bytes.len() {
316 let hi = hex_digit(bytes[i + 1])
317 .ok_or_else(|| format!("invalid percent-encoding at position {i}"))?;
318 let lo = hex_digit(bytes[i + 2])
319 .ok_or_else(|| format!("invalid percent-encoding at position {}", i + 1))?;
320 result.push((hi << 4) | lo);
321 i += 3;
322 } else {
323 result.push(bytes[i]);
324 i += 1;
325 }
326 }
327 String::from_utf8(result).map_err(|e| format!("invalid UTF-8 after percent-decoding: {e}"))
328}
329
330fn hex_digit(b: u8) -> Option<u8> {
331 match b {
332 b'0'..=b'9' => Some(b - b'0'),
333 b'a'..=b'f' => Some(b - b'a' + 10),
334 b'A'..=b'F' => Some(b - b'A' + 10),
335 _ => None,
336 }
337}