sesame/
unlock.rs

1use anyhow::Context;
2use core_auth::VaultAuthBackend as _;
3use core_ipc::BusClient;
4use core_types::{AuthFactorId, EventKind, SecurityLevel, SensitiveBytes, TrustProfileName};
5use owo_colors::OwoColorize;
6use zeroize::Zeroize;
7
8use crate::ipc::{connect, resolve_profile_specs, rpc};
9
10/// Submit a single factor via `FactorSubmit` IPC and handle the response.
11///
12/// Returns `Ok(true)` if the vault is now fully unlocked, `Ok(false)` if more
13/// factors are still needed (partial unlock accepted), or an error on rejection.
14async fn submit_factor(
15    client: &BusClient,
16    factor_id: AuthFactorId,
17    outcome: core_auth::UnlockOutcome,
18    profile: &TrustProfileName,
19) -> anyhow::Result<bool> {
20    let event = EventKind::FactorSubmit {
21        factor_id,
22        key_material: {
23            let (alloc, len) = outcome.master_key.into_protected_alloc();
24            SensitiveBytes::from_protected(alloc, len)
25        },
26        profile: profile.clone(),
27        audit_metadata: outcome.audit_metadata,
28    };
29
30    match rpc(client, event, SecurityLevel::SecretsOnly).await? {
31        EventKind::FactorResponse {
32            accepted: true,
33            unlock_complete: true,
34            profile: p,
35            ..
36        } => {
37            println!(
38                "{}",
39                format!("Vault '{p}' unlocked via {factor_id}.").green()
40            );
41            Ok(true)
42        }
43        EventKind::FactorResponse {
44            accepted: true,
45            unlock_complete: false,
46            remaining_factors,
47            remaining_additional,
48            ..
49        } => {
50            let remaining_names: Vec<String> =
51                remaining_factors.iter().map(|f| f.to_string()).collect();
52            tracing::debug!(
53                remaining = ?remaining_names,
54                additional = remaining_additional,
55                "factor accepted, more required"
56            );
57            Ok(false)
58        }
59        EventKind::FactorResponse {
60            accepted: false,
61            error,
62            ..
63        } => {
64            let msg = error.unwrap_or_else(|| "unknown error".into());
65            anyhow::bail!("factor {factor_id} rejected: {msg}");
66        }
67        EventKind::UnlockRejected {
68            reason: core_types::UnlockRejectedReason::AlreadyUnlocked,
69            profile: p,
70        } => {
71            println!(
72                "{}",
73                format!(
74                    "Vault '{}' already unlocked.",
75                    p.as_ref().map_or("unknown", |v| v.as_ref())
76                )
77                .yellow()
78            );
79            Ok(true)
80        }
81        other => anyhow::bail!("unexpected response: {other:?}"),
82    }
83}
84
85/// Try to submit a non-interactive factor (SSH agent).
86/// Returns `Ok(Some(true))` if vault is unlocked, `Ok(Some(false))` if partial,
87/// `Ok(None)` if factor was not attempted, or `Err` on hard failure.
88async fn try_auto_factor(
89    client: &BusClient,
90    factor_id: AuthFactorId,
91    meta: &core_auth::VaultMetadata,
92    profile: &TrustProfileName,
93    config_dir: &std::path::Path,
94    salt: &[u8],
95) -> anyhow::Result<Option<bool>> {
96    if !meta.has_factor(factor_id) {
97        return Ok(None);
98    }
99
100    match factor_id {
101        AuthFactorId::SshAgent => {
102            let backend = core_auth::SshAgentBackend::new();
103            if !backend.can_unlock(profile, config_dir).await {
104                return Ok(None);
105            }
106            match core_auth::VaultAuthBackend::unlock(&backend, profile, config_dir, salt).await {
107                Ok(outcome) => match submit_factor(client, factor_id, outcome, profile).await {
108                    Ok(complete) => Ok(Some(complete)),
109                    Err(e) => {
110                        tracing::debug!(error = %e, "SSH factor rejected");
111                        Ok(None)
112                    }
113                },
114                Err(e) => {
115                    tracing::debug!(error = %e, "SSH auto-unlock failed");
116                    Ok(None)
117                }
118            }
119        }
120        _ => Ok(None),
121    }
122}
123
124/// Prompt for password and submit as a factor.
125/// Returns `Ok(true)` if vault is unlocked, `Ok(false)` if partial.
126async fn prompt_password_factor(
127    client: &BusClient,
128    profile: &TrustProfileName,
129    profile_name: &str,
130    config_dir: &std::path::Path,
131    salt: &[u8],
132) -> anyhow::Result<bool> {
133    let mut password = if std::io::IsTerminal::is_terminal(&std::io::stdin()) {
134        dialoguer::Password::new()
135            .with_prompt(format!("Password for vault '{profile_name}'"))
136            .interact()
137            .context("failed to read password")?
138    } else {
139        let mut buf = String::new();
140        std::io::BufRead::read_line(&mut std::io::stdin().lock(), &mut buf)
141            .context("failed to read password from stdin")?;
142        if buf.ends_with('\n') {
143            buf.pop();
144            if buf.ends_with('\r') {
145                buf.pop();
146            }
147        }
148        if buf.is_empty() {
149            anyhow::bail!(
150                "empty password from stdin for vault '{profile_name}' — refusing to unlock"
151            );
152        }
153        buf
154    };
155
156    let mut password_sv = core_crypto::SecureVec::for_password();
157    for ch in password.chars() {
158        password_sv.push_char(ch);
159    }
160    password.zeroize();
161
162    let pw_backend = core_auth::PasswordBackend::new().with_password(password_sv);
163    let outcome = core_auth::VaultAuthBackend::unlock(&pw_backend, profile, config_dir, salt)
164        .await
165        .map_err(|e| {
166            anyhow::anyhow!(
167                "password unlock failed for vault '{profile_name}': {e}\n\
168                 Check your password or re-initialize the vault."
169            )
170        })?;
171
172    submit_factor(client, AuthFactorId::Password, outcome, profile).await
173}
174
175pub(crate) async fn cmd_unlock(profile_arg: Option<String>) -> anyhow::Result<()> {
176    let client = connect().await?;
177
178    let specs = resolve_profile_specs(profile_arg.as_deref());
179    let config_dir = core_config::config_dir();
180
181    for spec in &specs {
182        let profile_name = &spec.vault;
183        let target_profile = TrustProfileName::try_from(profile_name.as_str())
184            .map_err(|e| anyhow::anyhow!("invalid profile name '{profile_name}': {e}"))?;
185
186        let salt_path = config_dir
187            .join("vaults")
188            .join(format!("{target_profile}.salt"));
189        let salt = std::fs::read(&salt_path)
190            .context("failed to read vault salt — is the vault initialized?")?;
191
192        let meta = core_auth::VaultMetadata::load(&config_dir, &target_profile)
193            .context("failed to load vault metadata — is the vault initialized?")?;
194
195        // Collect enrolled factor IDs for iteration.
196        let enrolled: Vec<AuthFactorId> =
197            meta.enrolled_factors.iter().map(|f| f.factor_id).collect();
198
199        // Phase 1: Submit all non-interactive factors (SSH agent).
200        let mut unlocked = false;
201        for &factor in &enrolled {
202            if unlocked {
203                break;
204            }
205            match try_auto_factor(&client, factor, &meta, &target_profile, &config_dir, &salt)
206                .await?
207            {
208                Some(true) => {
209                    unlocked = true;
210                }
211                Some(false) => {
212                    tracing::debug!(
213                        factor = %factor,
214                        "auto factor accepted, more factors needed"
215                    );
216                }
217                None => {}
218            }
219        }
220
221        if unlocked {
222            continue;
223        }
224
225        // Phase 2: Query daemon for remaining factors.
226        let remaining = match rpc(
227            &client,
228            EventKind::VaultAuthQuery {
229                profile: target_profile.clone(),
230            },
231            SecurityLevel::SecretsOnly,
232        )
233        .await?
234        {
235            EventKind::VaultAuthQueryResponse {
236                enrolled_factors: ef,
237                partial_in_progress,
238                received_factors,
239                ..
240            } => {
241                if partial_in_progress {
242                    // Filter out already-received factors.
243                    ef.iter()
244                        .filter(|f| !received_factors.contains(f))
245                        .copied()
246                        .collect::<Vec<_>>()
247                } else {
248                    ef
249                }
250            }
251            _ => enrolled.clone(),
252        };
253
254        // Phase 3: Submit interactive factors.
255        for &factor in &remaining {
256            if unlocked {
257                break;
258            }
259            match factor {
260                AuthFactorId::Password => {
261                    if !meta.has_factor(AuthFactorId::Password) {
262                        continue;
263                    }
264                    match prompt_password_factor(
265                        &client,
266                        &target_profile,
267                        profile_name,
268                        &config_dir,
269                        &salt,
270                    )
271                    .await
272                    {
273                        Ok(true) => {
274                            unlocked = true;
275                        }
276                        Ok(false) => {
277                            tracing::debug!("password factor accepted, more factors needed");
278                        }
279                        Err(e) => {
280                            anyhow::bail!(
281                                "password unlock failed for vault '{profile_name}': {e}\n\
282                                 Check your password or re-initialize the vault."
283                            );
284                        }
285                    }
286                }
287                other => {
288                    // Future factors (FIDO2, TPM, etc.) are not yet implemented.
289                    anyhow::bail!(
290                        "vault '{profile_name}' requires factor '{other}' which is not \
291                         yet supported in this CLI version.\n\
292                         Enrolled factors: {enrolled:?}"
293                    );
294                }
295            }
296        }
297
298        if !unlocked {
299            anyhow::bail!(
300                "failed to unlock vault '{profile_name}' — all available factors exhausted.\n\
301                 Ensure your SSH key is loaded (ssh-add -l) or check your vault auth policy."
302            );
303        }
304    }
305
306    Ok(())
307}
308
309pub(crate) async fn cmd_lock(profile_arg: Option<String>) -> anyhow::Result<()> {
310    let client = connect().await?;
311
312    let target_profile = profile_arg
313        .map(|p| TrustProfileName::try_from(p.as_str()))
314        .transpose()
315        .map_err(|e| anyhow::anyhow!("invalid profile name: {e}"))?;
316
317    let event = EventKind::LockRequest {
318        profile: target_profile,
319    };
320
321    match rpc(&client, event, SecurityLevel::SecretsOnly).await? {
322        EventKind::LockResponse {
323            success: true,
324            profiles_locked,
325        } => {
326            if profiles_locked.is_empty() {
327                println!("{}", "All vaults locked. Key material zeroized.".green());
328            } else {
329                for p in &profiles_locked {
330                    println!(
331                        "{}",
332                        format!("Vault '{}' locked. Key material zeroized.", p).green()
333                    );
334                }
335            }
336        }
337        EventKind::LockResponse { success: false, .. } => {
338            anyhow::bail!("lock failed");
339        }
340        other => anyhow::bail!("unexpected response: {other:?}"),
341    }
342
343    Ok(())
344}