koi_certmesh/
core_lifecycle.rs1use super::*;
7
8impl CertmeshCore {
9 pub async fn create(
19 &self,
20 req: protocol::CreateCaRequest,
21 ) -> Result<protocol::CreateCaResponse, CertmeshError> {
22 let state = &self.state;
23
24 let entropy = match decode_hex(&req.entropy_hex) {
26 Some(bytes) if bytes.len() == 32 => bytes,
27 Some(bytes) => {
28 return Err(CertmeshError::InvalidPayload(format!(
29 "entropy must be exactly 32 bytes, got {}",
30 bytes.len()
31 )));
32 }
33 None => {
34 return Err(CertmeshError::InvalidPayload(
35 "invalid hex entropy".to_string(),
36 ));
37 }
38 };
39
40 if state.paths.is_ca_initialized() {
42 return Err(CertmeshError::Conflict(
43 "CA is already initialized".to_string(),
44 ));
45 }
46
47 let passphrase_clone = req.passphrase.clone();
49 let paths_clone = state.paths.clone();
50 let (ca_state, _master_key) = tokio::task::spawn_blocking(move || {
51 ca::create_ca(&passphrase_clone, &entropy, &paths_clone)
52 })
53 .await
54 .map_err(|e| CertmeshError::Internal(format!("CA creation task: {e}")))
55 .and_then(|r| r)?;
56 let ca_fingerprint = ca::ca_fingerprint(&ca_state);
57
58 let totp_secret = if let Some(ref hex) = req.totp_secret_hex {
62 match koi_common::encoding::hex_decode(hex) {
63 Ok(bytes) => koi_crypto::totp::TotpSecret::from_bytes(bytes),
64 Err(_) => {
65 return Err(CertmeshError::InvalidPayload(
66 "totp_secret_hex: invalid hex encoding".into(),
67 ));
68 }
69 }
70 } else {
71 koi_crypto::totp::generate_secret()
72 };
73 let stored = koi_crypto::auth::store_totp(&totp_secret, &req.passphrase)
74 .map_err(|e| CertmeshError::Internal(format!("auth store: {e}")))?;
75 let auth_json = serde_json::to_string_pretty(&stored)
76 .map_err(|e| CertmeshError::Internal(format!("auth serialize: {e}")))?;
77 {
78 let auth_path = state.paths.auth_path();
79 let auth_json_clone = auth_json.clone();
80 tokio::task::spawn_blocking(move || std::fs::write(&auth_path, &auth_json_clone))
81 .await
82 .map_err(|e| std::io::Error::other(format!("file I/O: {e}")))
83 .and_then(|r| r)
84 .map_err(CertmeshError::Io)?;
85 }
86
87 let totp_uri = koi_crypto::totp::build_totp_uri(&totp_secret, "Koi Certmesh", "enrollment");
88
89 let mut new_roster = roster::Roster::new(
92 req.enrollment_open,
93 req.requires_approval,
94 req.operator.clone(),
95 );
96 let roster_path = state.paths.roster_path();
97 roster::persist_roster(&new_roster, &roster_path).await?;
98
99 let local_hostname = hostname::get()
103 .map(|h| h.to_string_lossy().to_string())
104 .unwrap_or_else(|_| "localhost".to_string());
105 let sans = vec![
106 local_hostname.clone(),
107 "localhost".to_string(),
108 "127.0.0.1".to_string(),
109 "::1".to_string(),
110 ];
111 match ca::issue_certificate(
112 &ca_state,
113 &local_hostname,
114 &sans,
115 new_roster.metadata.policy.leaf_lifetime_days,
116 ) {
117 Ok(issued) => {
118 let cert_dir_base = state.paths.certs_dir().join(&local_hostname);
119 let cert_dir_base_clone = cert_dir_base.clone();
120 let issued_for_write = issued.clone();
121 let cert_dir = match tokio::task::spawn_blocking(move || {
122 certfiles::write_cert_files_to(&cert_dir_base_clone, &issued_for_write)
123 })
124 .await
125 {
126 Ok(Ok(dir)) => dir,
127 Ok(Err(e)) => {
128 tracing::warn!(error = %e, "Could not write CA node cert files");
129 cert_dir_base
130 }
131 Err(e) => {
132 tracing::warn!(error = %e, "Cert file write task panicked");
133 cert_dir_base
134 }
135 };
136 let ca_fp = ca::ca_fingerprint(&ca_state);
137 let member = roster::RosterMember {
138 hostname: local_hostname.clone(),
139 role: roster::MemberRole::Primary,
140 enrolled_at: chrono::Utc::now(),
141 enrolled_by: req.operator.clone(),
142 cert_fingerprint: issued.fingerprint,
143 cert_expires: issued.expires,
144 cert_sans: sans,
145 cert_path: cert_dir.display().to_string(),
146 status: roster::MemberStatus::Active,
147 reload_hook: None,
148 last_seen: Some(chrono::Utc::now()),
149 pinned_ca_fingerprint: Some(ca_fp),
150 proxy_entries: Vec::new(),
151 };
152 new_roster.members.push(member);
153 if let Err(e) = roster::persist_roster(&new_roster, &roster_path).await {
155 tracing::warn!(error = %e, "Could not save roster after self-enrollment");
156 }
157 let _ = audit::append_entry_to(
158 &state.paths.audit_log_path(),
159 "member_joined",
160 &[
161 ("hostname", local_hostname.as_str()),
162 ("role", "primary"),
163 ("approved_by", "self-enroll"),
164 ],
165 );
166 tracing::info!(hostname = %local_hostname, "CA node self-enrolled as primary");
167 }
168 Err(e) => {
169 tracing::warn!(error = %e, "Could not self-enroll CA node - roster will be empty");
170 }
171 }
172
173 if let Err(e) = os_truststore::Cert::from_pem(&ca_state.cert_pem)
175 .and_then(|cert| os_truststore::install(&cert).map(drop))
176 {
177 tracing::warn!(error = %e, "Could not install CA cert in trust store");
178 }
179
180 if let Err(e) = self.configure_auto_unlock(req.auto_unlock, &req.passphrase) {
186 tracing::warn!(error = %e, "Could not configure auto-unlock");
187 }
188
189 match koi_crypto::vault::machine_fingerprint() {
194 Some(fp) => {
195 let path = state.paths.machine_bind_path();
196 let r = tokio::task::spawn_blocking(move || write_machine_binding(&path, &fp))
197 .await
198 .map_err(|e| std::io::Error::other(format!("machine-bind task: {e}")))
199 .and_then(|r| r);
200 if let Err(e) = r {
201 tracing::warn!(error = %e, "Could not record machine binding");
202 }
203 }
204 None => tracing::debug!(
205 "machine-id unavailable; machine binding not recorded (auto-unlock unchecked)"
206 ),
207 }
208
209 *state.ca.lock().await = Some(ca_state);
211 *state.auth.lock().await = Some(koi_crypto::auth::AuthState::Totp(totp_secret));
212 *state.roster.lock().await = new_roster;
213
214 let _ = audit::append_entry_to(
215 &state.paths.audit_log_path(),
216 "ca_initialized",
217 &[
218 (
219 "enrollment_open",
220 if req.enrollment_open {
221 "open"
222 } else {
223 "closed"
224 },
225 ),
226 (
227 "requires_approval",
228 if req.requires_approval { "yes" } else { "no" },
229 ),
230 ("operator", req.operator.as_deref().unwrap_or("none")),
231 ],
232 );
233
234 tracing::info!(
235 enrollment_open = req.enrollment_open,
236 requires_approval = req.requires_approval,
237 auto_unlock = req.auto_unlock,
238 "CA initialized via service"
239 );
240
241 state.republish_posture();
243
244 Ok(protocol::CreateCaResponse {
245 auth_setup: koi_crypto::auth::AuthSetup::Totp { totp_uri },
246 ca_fingerprint,
247 })
248 }
249
250 pub fn read_audit_log(&self) -> Result<String, CertmeshError> {
252 audit::read_log_from(&self.state.paths.audit_log_path()).map_err(CertmeshError::Io)
253 }
254
255 pub async fn destroy(&self) -> Result<(), CertmeshError> {
261 self.state.destroy().await?;
262 let _ = self.state.event_tx.send(CertmeshEvent::Destroyed);
263 Ok(())
264 }
265}