Skip to main content

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}