Skip to main content

koi_certmesh/
core_member.rs

1//! Enrollment window and member CSR/cert custody.
2//!
3//! Part of the inherent impl CertmeshCore, split from lib.rs (certmesh M2).
4//! As a child module of the crate root, 'use super::*' inherits lib.rs's
5//! imports, sibling modules, and crate-private state/helpers as in the original.
6use super::*;
7
8impl CertmeshCore {
9    // ── Enrollment toggle ───────────────────────────────────────────
10
11    /// Open the enrollment window. Stays open until explicitly closed.
12    pub async fn open_enrollment(&self) -> Result<(), CertmeshError> {
13        // Posture change → single-writer commit so a concurrent enroll can't
14        // overwrite it with a stale snapshot (F8). Not bundle content → no bump.
15        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    /// Close the enrollment window.
29    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    /// Mint a single-use, hostname-bound enrollment invite (ADR-015 F2).
44    ///
45    /// Returns the one-time plaintext token plus its absolute expiry. The CA
46    /// stores only a hash; the joining host presents the token once via
47    /// `POST /join` (`invite_token`). The mesh must be initialized — the invite
48    /// is an authorization to enroll into an existing CA. Posture booleans are
49    /// unchanged: the token replaces the credential, not the `enrollment_open` /
50    /// `requires_approval` gates.
51    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 the hostname the same way enrollment will (it becomes the
60        // single host this token authorizes — and a certificate SAN at join, F15).
61        validate_hostname(hostname)?;
62
63        // The CA fingerprint the joiner will pin (ADR-017 F3). `is_ca_initialized`
64        // was checked above, so `ca_fingerprint()` (in-memory or on-disk, never
65        // holding a lock across I/O) yields the public CA fingerprint here.
66        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        // The operator-facing code carries the pinned CA fingerprint (F3) so the
74        // joiner can preflight + pin before sending its CSR.
75        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    // ── Member-side key custody (ADR-015 F1) ────────────────────────
93
94    /// Generate this member's keypair + CSR and persist the **private key** locally.
95    ///
96    /// The daemon generates the keypair, writes the private key to
97    /// `certs/<hostname>/key.pem` (0600 on Unix), and returns only the CSR. The
98    /// key never leaves the daemon; the CLI carries only the public CSR to the
99    /// remote CA. Paired with [`Self::install_member_cert`].
100    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    /// Install a CA-signed leaf next to the member key from [`Self::prepare_member_csr`].
131    ///
132    /// Writes `cert.pem`, `ca.pem`, and `fullchain.pem` into `certs/<hostname>/`
133    /// (the key is already there) and installs the CA root in the OS trust store
134    /// (best-effort). Returns the cert directory path.
135    ///
136    /// When `ca_endpoint` + `ca_fingerprint` are supplied (the normal join flow),
137    /// it also writes the **member renewal state** (`certmesh/member.json`) so the
138    /// background loop can later pull a rotate-key renewal from the CA over mTLS
139    /// (ADR-017 F6). The pinned `ca_fingerprint` is verified against the supplied
140    /// `ca_pem` before arming, so a mismatched pair never arms renewal.
141    #[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        // Enforce the pin BEFORE writing anything (ADR-017 F3). When the caller
155        // supplied a pinned fingerprint (the out-of-band-trusted one from the
156        // invite), the CA cert we are about to install + trust MUST match it, or we
157        // refuse — a MITM that intercepted the plain-HTTP join and substituted its
158        // own CA is rejected here, before any file is written or any root is
159        // trusted. Without a pin (TOTP join), this is a documented TOFU install.
160        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        // Trust the CA root so this node can verify the mesh (best-effort).
189        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        // Arm member-pull renewal when the join supplied the CA coordinates. The
196        // pinned fingerprint was already verified against `ca_pem` above, so the
197        // MemberState records a fingerprint we have confirmed matches the installed
198        // CA root.
199        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        // Leaf (and possibly member.json) now on disk → Open→Authenticated.
221        self.state.republish_posture();
222
223        Ok(cert_dir.display().to_string())
224    }
225}