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
10async 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
85async 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
124async 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 let enrolled: Vec<AuthFactorId> =
197 meta.enrolled_factors.iter().map(|f| f.factor_id).collect();
198
199 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 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 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 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 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}