Skip to main content

koi_certmesh/
core_lifecycle.rs

1//! CA lifecycle: create, audit-log read, and destroy.
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    /// Initialize a new CA and self-enroll this node as the primary member.
10    ///
11    /// Full CA-initialization orchestration: decode entropy, create the CA,
12    /// generate the TOTP auth credential, create and persist the roster,
13    /// self-enroll the CA node, install the CA cert in the OS trust store
14    /// (best-effort), configure auto-unlock, and update in-memory state.
15    ///
16    /// This is the single source of truth for CA creation; the HTTP
17    /// `create_handler` is a thin delegate over this method.
18    pub async fn create(
19        &self,
20        req: protocol::CreateCaRequest,
21    ) -> Result<protocol::CreateCaResponse, CertmeshError> {
22        let state = &self.state;
23
24        // Decode hex entropy (must be exactly 32 bytes)
25        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        // Reject if CA already initialized
41        if state.paths.is_ca_initialized() {
42            return Err(CertmeshError::Conflict(
43                "CA is already initialized".to_string(),
44            ));
45        }
46
47        // Create CA (blocking I/O: key gen, file writes, slot table save)
48        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        // Generate auth credential (default=TOTP).
59        // If the client provided a ceremony-verified secret, use it;
60        // otherwise generate a fresh one.
61        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        // Create roster from the two posture booleans (the named preset, if any,
90        // was already resolved to these by the ceremony/CLI).
91        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        // Self-enroll the CA node as the first (primary) member.
100        // This issues a certificate for the local hostname so applications
101        // on this machine can use TLS immediately.
102        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                // Persist updated roster with the self-enrolled member
154                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        // Install CA cert in OS trust store (best-effort)
174        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        // Configure auto-unlock from the create-time decision (single source of
181        // truth: CertmeshCore::configure_auto_unlock). When `auto_unlock` is true,
182        // the passphrase is saved to the koi-crypto vault so the daemon boots
183        // unlocked; the slot table is marked. This is what keeps the boot-unlocked
184        // path (koi-compose init_certmesh_core) working.
185        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        // Record this machine's fingerprint (ADR-017 F11) so a later boot can
190        // detect a VM clone / disk restore onto different hardware and refuse to
191        // auto-unlock. Best-effort: if the machine-id is unreadable, the CA is
192        // simply not machine-checked.
193        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        // Update in-memory state
210        *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        // The CA node self-enrolled a leaf above → Open→Authenticated.
242        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    /// Read the audit log entries.
251    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    /// Destroy all certmesh state - CA key, certs, roster, and audit log.
256    ///
257    /// Removes all certmesh data from disk and resets in-memory state to
258    /// uninitialized. This is irreversible. Used for testing cleanup and
259    /// full mesh teardown.
260    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}