// Most of this module is dead code on web as it is not possible to retrieve // external secrets from the browser. #![cfg_attr(target_family = "wasm", allow(dead_code, unused_variables))] use anyhow::anyhow; use core::fmt; use galaxy_util::path::ShellFamily; use itertools::Itertools; use lazy_static::lazy_static; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::path::PathBuf; #[cfg(all(not(target_family = "wasm"), feature = "local_tty"))] use crate::terminal::local_shell::execute_command; use crate::{terminal::shell::ShellType, ui_components::icons::Icon}; lazy_static! { // Used as a delimeter to separate metadata (such as names and references) // in cases the cli tool doesn't display secrets in a common format (i.e. json) static ref WARP_SECRET_DELIMETER: &'static str = "/warp-secret-delimeter/"; static ref LASTPASS_LIST_SECRETS_COMMAND: Vec = { vec![ "lpass".to_owned(), "ls".to_owned(), format!("--format=%an{}%ai", *WARP_SECRET_DELIMETER), ] }; } const ONE_PASSWORD_INSTALLED_COMMAND: [&str; 2] = ["op", "-v"]; // 1Password has more categories (logins, servers, etc), // but we're limiting our support to these as categories // may differ in the way they extract sensitive information // (i.e. api credentials use the --credential field seen // in get_secret_extraction_command, whereas logins use // the --password field). We're waiting to see how users // use secrets to inform what to support. const ONE_PASSWORD_LIST_SECRETS_COMMAND: [&str; 6] = [ "op", "item", "list", "--categories", "Database,Api\\ Credential", "--format=json", ]; const LASTPASS_INSTALLED_COMMAND: [&str; 2] = ["lpass", "-v"]; const ONEPASSWORD_DOCS_LINK: &str = "https://developer.1password.com/docs/cli/get-started/"; const LASTPASS_DOCS_LINK: &str = "https://github.com/lastpass/lastpass-cli"; /// Represents a "completed" secret #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub enum ExternalSecret { OnePassword(OnePasswordSecret), LastPass(LastPassSecret), } impl ExternalSecret { pub fn get_secret_extraction_command(&self, shell_family: ShellFamily) -> String { let prefix = match shell_family { ShellFamily::Posix => "\\", ShellFamily::PowerShell => "", }; match self { ExternalSecret::OnePassword(secret) => { format!( "{}op item get --fields credential --reveal {}", prefix, secret.reference ) } ExternalSecret::LastPass(secret) => { format!("{}lpass show --password {}", prefix, secret.reference) } } } pub fn get_display_name(&self) -> String { match self { ExternalSecret::OnePassword(secret) => secret.name.clone(), ExternalSecret::LastPass(secret) => secret.name.clone(), } } } /// Used to check if a secret manager is installed/fetch list of secrets #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub enum SecretManager { OnePassword, LastPass, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub enum SecretErrorType { NotInstalled, FetchFailed, InvalidPlatform, } pub struct ErrorMessageAndCommand { pub message: String, pub link_message: Option, pub link: Option, } impl SecretManager { async fn is_installed( &self, shell_type: ShellType, shell_path: PathBuf, path_env_var: Option, ) -> bool { #[cfg(all(not(target_family = "wasm"), feature = "local_tty"))] { match self { SecretManager::OnePassword => { return execute_command( shell_type, shell_path, path_env_var, ONE_PASSWORD_INSTALLED_COMMAND.join(" ").as_str(), ) .await .is_ok() } SecretManager::LastPass => { return execute_command( shell_type, shell_path, path_env_var, LASTPASS_INSTALLED_COMMAND.join(" ").as_str(), ) .await .is_ok() } } } #[allow(unreachable_code)] false } async fn fetch_secrets( &self, shell_type: ShellType, shell_path: PathBuf, path_env_var: Option, ) -> Option> { #[cfg(all(not(target_family = "wasm"), feature = "local_tty"))] { match self { SecretManager::OnePassword => { return execute_command( shell_type, shell_path, path_env_var, ONE_PASSWORD_LIST_SECRETS_COMMAND.join(" ").as_str(), ) .await .ok() .and_then(|output| parse_onepassword_secrets(&output).ok()) } SecretManager::LastPass => { let lastpass_command: Vec<&str> = LASTPASS_LIST_SECRETS_COMMAND .iter() .map(|s| s.as_str()) .collect(); return execute_command( shell_type, shell_path, path_env_var, lastpass_command.join(" ").as_str(), ) .await .ok() .and_then(|output| parse_lastpass_secrets(&output).ok()); } } } #[allow(unreachable_code)] None } pub async fn verify_installed_and_fetch_secrets( &self, shell_type: ShellType, shell_path: PathBuf, path_env_var: Option, ) -> Result, SecretErrorType> { #[cfg(not(target_family = "wasm"))] { let is_installed = self .is_installed(shell_type, shell_path.clone(), path_env_var.clone()) .await; if !is_installed { return Err(SecretErrorType::NotInstalled); } let secrets = self .fetch_secrets(shell_type, shell_path, path_env_var) .await; if let Some(secrets) = secrets { return Ok(secrets); } else { return Err(SecretErrorType::FetchFailed); } } #[allow(unreachable_code)] Err(SecretErrorType::InvalidPlatform) } pub fn get_toast_message_and_link( &self, error_type: SecretErrorType, ) -> ErrorMessageAndCommand { match error_type { SecretErrorType::NotInstalled => { let message = format!("{} CLI is not installed", &self); let (link, link_message) = ( match self { SecretManager::OnePassword => Some(ONEPASSWORD_DOCS_LINK.to_owned()), SecretManager::LastPass => Some(LASTPASS_DOCS_LINK.to_owned()), }, Some(format!("View {} CLI installation documentation", &self)), ); ErrorMessageAndCommand { message, link, link_message, } } SecretErrorType::FetchFailed => { let (link, link_message) = match self { SecretManager::OnePassword => ( Some(ONEPASSWORD_DOCS_LINK.to_owned()), Some("Integrate 1Password app with CLI".to_owned()), ), SecretManager::LastPass => (None, None), }; ErrorMessageAndCommand { message: format!( "{} didn't return secrets (likely not configured or authenticated)", &self ), link, link_message, } } SecretErrorType::InvalidPlatform => ErrorMessageAndCommand { message: "Platform not supported".to_owned(), link: None, link_message: None, }, } } } impl fmt::Display for SecretManager { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { SecretManager::OnePassword => write!(f, "1Password"), SecretManager::LastPass => write!(f, "LastPass"), } } } pub trait ExternalSecretManager { fn icon(&self) -> Icon; } impl ExternalSecretManager for ExternalSecret { fn icon(&self) -> Icon { match self { ExternalSecret::OnePassword(_) => Icon::OnePassword, ExternalSecret::LastPass(_) => Icon::LastPass, } } } impl ExternalSecretManager for SecretManager { fn icon(&self) -> Icon { match self { SecretManager::OnePassword => Icon::OnePassword, SecretManager::LastPass => Icon::LastPass, } } } fn parse_onepassword_secrets(output: &str) -> anyhow::Result> { let parsed_output: Vec = serde_json::from_str::(output) .map_err(|e| anyhow!(e))? .as_array() .ok_or(anyhow!("Expected array in JSON"))? .iter() .map(|secret| { let name = secret .get("title") .and_then(|v| v.as_str()) .ok_or(anyhow!("Secret is missing title"))?; let reference = secret .get("id") .and_then(|v| v.as_str()) .ok_or(anyhow!("Secret is missing id"))?; Ok(ExternalSecret::OnePassword(OnePasswordSecret { name: name.to_string(), reference: reference.to_string(), })) }) .collect::, anyhow::Error>>()?; Ok(parsed_output) } fn parse_lastpass_secrets(output: &str) -> anyhow::Result> { let parsed_output: Vec = output .lines() .filter_map(|line| { let parts = line.split(*WARP_SECRET_DELIMETER).collect_vec(); if parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() { Some(ExternalSecret::LastPass(LastPassSecret { name: parts[0].to_owned(), reference: parts[1].to_owned(), })) } else { None } }) .collect(); if !parsed_output.is_empty() { Ok(parsed_output) } else { Err(anyhow!("Failed to parse any secrets")) } } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct OnePasswordSecret { name: String, reference: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct LastPassSecret { name: String, reference: String, }