koi_certmesh/
core_member.rs1use super::*;
7
8impl CertmeshCore {
9 pub async fn open_enrollment(&self) -> Result<(), CertmeshError> {
13 self.state
16 .touch_roster(|roster| {
17 roster.open_enrollment();
18 Ok(())
19 })
20 .await?;
21
22 tracing::info!("Enrollment window opened");
23 let _ =
24 audit::append_entry_to(&self.state.paths.audit_log_path(), "enrollment_opened", &[]);
25 Ok(())
26 }
27
28 pub async fn close_enrollment(&self) -> Result<(), CertmeshError> {
30 self.state
31 .touch_roster(|roster| {
32 roster.close_enrollment();
33 Ok(())
34 })
35 .await?;
36
37 tracing::info!("Enrollment window closed");
38 let _ =
39 audit::append_entry_to(&self.state.paths.audit_log_path(), "enrollment_closed", &[]);
40 Ok(())
41 }
42
43 pub async fn mint_invite(
52 &self,
53 hostname: &str,
54 ttl_mins: i64,
55 ) -> Result<protocol::InviteResponse, CertmeshError> {
56 if !self.state.paths.is_ca_initialized() {
57 return Err(CertmeshError::CaNotInitialized);
58 }
59 validate_hostname(hostname)?;
62
63 let ca_fingerprint = self
67 .ca_fingerprint()
68 .await
69 .ok_or(CertmeshError::CaNotInitialized)?;
70
71 let minted = invite::mint(&self.state.paths.invites_path(), hostname, ttl_mins)?;
72 let expires_at = minted.expires_at.to_rfc3339();
73 let code = invite::encode_code(&minted.token, &ca_fingerprint);
76
77 let _ = audit::append_entry_to(
78 &self.state.paths.audit_log_path(),
79 "invite_minted",
80 &[("hostname", hostname), ("expires_at", &expires_at)],
81 );
82 tracing::info!(hostname, "Enrollment invite minted");
83
84 Ok(protocol::InviteResponse {
85 token: code,
86 hostname: hostname.to_string(),
87 expires_at,
88 ca_fingerprint,
89 })
90 }
91
92 pub async fn prepare_member_csr(
101 &self,
102 hostname: &str,
103 sans: &[String],
104 ) -> Result<String, CertmeshError> {
105 validate_hostname(hostname)?;
106 let (key_pem, csr_pem) = csr::generate_keypair_and_csr(hostname, sans)?;
107
108 let cert_dir = self.state.paths.certs_dir().join(hostname);
109 let key_path = cert_dir.join("key.pem");
110 tokio::task::spawn_blocking(move || -> Result<(), CertmeshError> {
111 std::fs::create_dir_all(&cert_dir)?;
112 std::fs::write(&key_path, key_pem.as_bytes())?;
113 #[cfg(unix)]
114 {
115 use std::os::unix::fs::PermissionsExt;
116 std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600))?;
117 }
118 Ok(())
119 })
120 .await
121 .map_err(|e| CertmeshError::Internal(format!("write member key task: {e}")))??;
122
123 tracing::info!(
124 hostname,
125 "Member keypair generated; CSR prepared (key kept local)"
126 );
127 Ok(csr_pem)
128 }
129
130 #[allow(clippy::too_many_arguments)]
142 pub async fn install_member_cert(
143 &self,
144 hostname: &str,
145 cert_pem: &str,
146 ca_pem: &str,
147 ca_endpoint: Option<&str>,
148 ca_fingerprint: Option<&str>,
149 sans: &[String],
150 policy: Option<roster::CertPolicy>,
151 ) -> Result<String, CertmeshError> {
152 validate_hostname(hostname)?;
153
154 if let Some(expected_fp) = ca_fingerprint {
161 let der = pem::parse(ca_pem).map_err(|e| {
162 CertmeshError::InvalidPayload(format!("CA cert is not valid PEM: {e}"))
163 })?;
164 let actual_fp = koi_crypto::pinning::fingerprint_sha256(der.contents());
165 if !koi_crypto::pinning::fingerprints_match(&actual_fp, expected_fp) {
166 return Err(CertmeshError::InvalidPayload(format!(
167 "installed CA cert fingerprint {actual_fp} does not match the pinned \
168 fingerprint {expected_fp} (possible MITM) — refusing to install"
169 )));
170 }
171 }
172
173 let cert_dir = self.state.paths.certs_dir().join(hostname);
174 let cert_owned = cert_pem.to_string();
175 let ca_owned = ca_pem.to_string();
176 let fullchain = format!("{cert_owned}{ca_owned}");
177 let dir = cert_dir.clone();
178 tokio::task::spawn_blocking(move || -> Result<(), CertmeshError> {
179 std::fs::create_dir_all(&dir)?;
180 write_file_atomic(&dir.join("cert.pem"), cert_owned.as_bytes(), false)?;
181 write_file_atomic(&dir.join("ca.pem"), ca_owned.as_bytes(), false)?;
182 write_file_atomic(&dir.join("fullchain.pem"), fullchain.as_bytes(), false)?;
183 Ok(())
184 })
185 .await
186 .map_err(|e| CertmeshError::Internal(format!("write member cert task: {e}")))??;
187
188 if let Err(e) = os_truststore::Cert::from_pem(ca_pem)
190 .and_then(|cert| os_truststore::install(&cert).map(drop))
191 {
192 tracing::warn!(error = %e, "Could not install CA cert in trust store");
193 }
194
195 if let (Some(endpoint), Some(fingerprint)) = (ca_endpoint, ca_fingerprint) {
200 let state = member::MemberState {
201 hostname: hostname.to_string(),
202 ca_host: member::host_from_endpoint(endpoint),
203 ca_mtls_port: member::DEFAULT_CA_MTLS_PORT,
204 ca_http_port: member::port_from_endpoint(endpoint),
205 ca_fingerprint: fingerprint.to_string(),
206 sans: sans.to_vec(),
207 policy: policy.unwrap_or_default(),
208 last_bundle_seq: 0,
209 reload_hook: None,
210 };
211 if let Err(e) = member::save(&self.state.paths.member_state_path(), &state) {
212 tracing::warn!(error = %e, "Could not persist member renewal state");
213 } else {
214 tracing::info!(hostname, ca_host = %state.ca_host, "Member renewal state armed");
215 }
216 }
217
218 tracing::info!(hostname, "Member certificate installed locally");
219
220 self.state.republish_posture();
222
223 Ok(cert_dir.display().to_string())
224 }
225}