koi_certmesh/core_auth.rs
1//! Reload hooks, member roles, and CA unlock / auto-unlock.
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 /// Set the post-renewal reload hook for a member.
10 pub async fn set_reload_hook(&self, hostname: &str, hook: &str) -> Result<(), CertmeshError> {
11 // Validate at domain boundary — all callers (HTTP, embedded, CLI) are
12 // protected by the single source of truth in `validate_reload_hook`.
13 validate_reload_hook(hook)?;
14 // touch_roster: reload_hook is not bundle content, so no seq bump — but
15 // the write still serializes behind the single writer (F8).
16 self.state
17 .touch_roster(|roster| {
18 let member = roster.find_member_mut(hostname).ok_or_else(|| {
19 CertmeshError::NotFound(format!("member not found: {hostname}"))
20 })?;
21 member.reload_hook = Some(hook.to_string());
22 Ok(())
23 })
24 .await?;
25
26 tracing::info!(hostname, hook, "Reload hook set");
27 Ok(())
28 }
29
30 /// Set the role of a member in the roster.
31 pub async fn set_member_role(
32 &self,
33 hostname: &str,
34 role: roster::MemberRole,
35 ) -> Result<(), CertmeshError> {
36 self.state
37 .touch_roster(|roster| {
38 let member = roster.find_member_mut(hostname).ok_or_else(|| {
39 CertmeshError::Internal(format!("member not found: {hostname}"))
40 })?;
41 member.role = role.clone();
42 Ok(())
43 })
44 .await?;
45
46 tracing::info!(hostname, role = ?role, "Member role updated");
47 Ok(())
48 }
49
50 /// Unlock the CA with a passphrase.
51 pub async fn unlock(&self, passphrase: &str) -> Result<(), CertmeshError> {
52 let ca_state = match ca::load_ca(passphrase, &self.state.paths) {
53 Ok(ca) => ca,
54 Err(e) => {
55 // Audit the failed unlock before returning (ADR-017 F9/F14).
56 let _ = audit::append_entry_to(
57 &self.state.paths.audit_log_path(),
58 "unlock_failed",
59 &[("via", "passphrase")],
60 );
61 return Err(e);
62 }
63 };
64
65 // Load auth credential from auth.json
66 let auth_path = self.state.paths.auth_path();
67 if auth_path.exists() {
68 let json = std::fs::read_to_string(&auth_path)?;
69 let stored: koi_crypto::auth::StoredAuth = serde_json::from_str(&json)
70 .map_err(|e| CertmeshError::Internal(format!("auth.json parse error: {e}")))?;
71 let auth_state = stored
72 .unlock(passphrase)
73 .map_err(|e| CertmeshError::Internal(format!("auth unlock failed: {e}")))?;
74 *self.state.auth.lock().await = Some(auth_state);
75 }
76
77 *self.state.ca.lock().await = Some(ca_state);
78
79 tracing::info!("CA unlocked");
80 Ok(())
81 }
82
83 /// Unlock the CA with a pre-unwrapped master key (TOTP or auto-unlock).
84 ///
85 /// This bypasses passphrase-based auth.json decryption. The auth
86 /// credential (for API gating) is not loaded - callers should use
87 /// the slot table's embedded TOTP shared_secret for verification
88 /// if auth gating is needed.
89 pub async fn unlock_with_master_key(&self, master_key: &[u8; 32]) -> Result<(), CertmeshError> {
90 let ca_state = ca::load_ca_with_master_key(master_key, &self.state.paths)?;
91 *self.state.ca.lock().await = Some(ca_state);
92 tracing::info!("CA unlocked via master key (non-passphrase slot)");
93 Ok(())
94 }
95
96 /// Unlock the CA using a TOTP code against the unlock slot table.
97 ///
98 /// Loads the slot table, verifies the TOTP code, unwraps the master
99 /// key, and decrypts the CA key.
100 pub async fn unlock_with_totp(&self, code: &str) -> Result<(), CertmeshError> {
101 let slot_table =
102 ca::load_slot_table(&self.state.paths.slot_table_path())?.ok_or_else(|| {
103 CertmeshError::NoSlotFound(
104 "no slot table found - CA may use legacy passphrase format".into(),
105 )
106 })?;
107
108 if !slot_table.has_totp_slot() {
109 return Err(CertmeshError::NoSlotFound(
110 "TOTP unlock is not configured for this CA".into(),
111 ));
112 }
113
114 let master_key = slot_table.unwrap_with_totp(code).map_err(|e| {
115 let msg = e.to_string();
116 if msg.contains("invalid TOTP code") {
117 CertmeshError::InvalidAuth
118 } else {
119 CertmeshError::Crypto(msg)
120 }
121 })?;
122
123 self.unlock_with_master_key(&master_key).await
124 }
125
126 // ── Auto-unlock key management ──────────────────────────────────
127
128 /// Vault key under which the auto-unlock passphrase is stored.
129 const VAULT_AUTO_UNLOCK_KEY: &'static str = "certmesh-auto-unlock";
130
131 /// Save a passphrase for automatic unlock on reboot, rooted at explicit
132 /// paths so the vault is co-located with the CA it unlocks.
133 ///
134 /// Uses the koi-crypto vault which automatically selects the strongest
135 /// available backend: platform credential store (DPAPI, Keychain,
136 /// Secret Service) first, machine-bound Argon2id derivation as fallback.
137 /// The counterpart reader is [`Self::read_auto_unlock_key`].
138 pub fn save_auto_unlock_key_at(
139 paths: &CertmeshPaths,
140 passphrase: &str,
141 ) -> Result<(), CertmeshError> {
142 let vault = koi_crypto::vault::Vault::open(paths.data_dir())?;
143 vault.store(Self::VAULT_AUTO_UNLOCK_KEY, passphrase)?;
144 tracing::info!(
145 backend = vault.backend_name(),
146 "Auto-unlock key saved to vault"
147 );
148 // Remove any legacy file/credential store entries
149 let _ = std::fs::remove_file(paths.auto_unlock_key_path());
150 let _ = koi_crypto::tpm::delete_key_material("koi-auto-unlock");
151 Ok(())
152 }
153
154 /// Read the stored auto-unlock passphrase from the vault, if any.
155 ///
156 /// The auto-unlock passphrase lives in the koi-crypto vault (written by
157 /// [`Self::save_auto_unlock_key_at`], which deletes any legacy plaintext
158 /// file). This is the **single source of truth** for that location:
159 /// boot paths that need to unlock the CA at construction time call this
160 /// instead of reading a plaintext file that no longer exists.
161 ///
162 /// Returns `Ok(None)` when no key is stored, `Ok(Some(pp))` when one is
163 /// found, and `Err` when the vault cannot be opened or read.
164 pub fn read_auto_unlock_key(
165 paths: &CertmeshPaths,
166 ) -> Result<Option<Zeroizing<String>>, CertmeshError> {
167 let vault = koi_crypto::vault::Vault::open(paths.data_dir())?;
168 Ok(match vault.retrieve(Self::VAULT_AUTO_UNLOCK_KEY)? {
169 Some(pp) if !pp.is_empty() => Some(Zeroizing::new(pp)),
170 _ => None,
171 })
172 }
173
174 /// Try to auto-unlock the CA from the vault.
175 ///
176 /// Returns `Ok(true)` if the CA was unlocked, `Ok(false)` if no
177 /// stored key exists, and `Err` if the key exists but decryption
178 /// failed (corrupt key, changed passphrase, etc.).
179 pub async fn try_auto_unlock(&self) -> Result<bool, CertmeshError> {
180 // F11: refuse auto-unlock if the machine fingerprint changed since the CA
181 // was created (a VM clone / disk restore onto new hardware). Fail-safe —
182 // boot LOCKED and require a manual passphrase. Checked BEFORE touching the
183 // vault so a cloned host can't auto-unlock with the copied vault key.
184 // `machine_binding_ok` shells out on Windows/macOS, so run it off the
185 // executor. (The real daemon boot path is `koi_compose::init_certmesh_core`,
186 // which gates auto-unlock with the same free function — this method mirrors
187 // it for embedded/programmatic callers.)
188 let paths = self.state.paths.clone();
189 let bound_ok = tokio::task::spawn_blocking(move || machine_binding_ok(&paths))
190 .await
191 .unwrap_or(true);
192 if !bound_ok {
193 let _ = audit::append_entry_to(
194 &self.state.paths.audit_log_path(),
195 "auto_unlock_refused_machine_changed",
196 &[],
197 );
198 tracing::error!(
199 "machine fingerprint changed since CA creation (clone/restore?) — refusing \
200 auto-unlock. Run `koi certmesh unlock` to unlock manually on this host."
201 );
202 return Ok(false);
203 }
204
205 let passphrase = match Self::read_auto_unlock_key(&self.state.paths)? {
206 Some(pp) => pp,
207 None => return Ok(false),
208 };
209 self.unlock(&passphrase).await?;
210 tracing::info!("CA auto-unlocked via vault");
211 Ok(true)
212 }
213
214 /// Configure auto-unlock from the create-time `auto_unlock` decision.
215 ///
216 /// This is the **single source of truth** for the unlock-on-boot decision.
217 /// When `auto_unlock` is true and a passphrase is present, the passphrase
218 /// is saved to the koi-crypto vault (read back at boot by
219 /// [`Self::read_auto_unlock_key`]) and the slot table is marked. Call it
220 /// after CA creation from any init path (direct API or ceremony).
221 pub fn configure_auto_unlock(
222 &self,
223 auto_unlock: bool,
224 passphrase: &str,
225 ) -> Result<(), CertmeshError> {
226 if auto_unlock && !passphrase.is_empty() {
227 let paths = self.paths();
228 Self::save_auto_unlock_key_at(paths, passphrase)?;
229
230 // Mark auto-unlock in the slot table (if it exists)
231 let slot_path = paths.slot_table_path();
232 if let Some(mut table) = ca::load_slot_table(&slot_path)? {
233 table.add_auto_unlock();
234 ca::save_slot_table(&table, &slot_path)?;
235 }
236 }
237 Ok(())
238 }
239}