// secrets_always_local_mod.rs /// Secrets like GitHub API secret_token, crates.io secret token, docker hub secret_token, SSH private key passphrase and similar /// must never go out of this crate. Never pass any secret to an external crate library as much as possible. /// The user has the source code under his fingers in this crate. So he knows nobody will mess with this code /// once he inspected and reviewed it. /// All the modules are in one file to avoid clutter in the automation_tasks_rs folder. /// The simple program flow of functions that need secrets is butchered to avoid secrets leaving this crate. /// Now it looks like a mess, but the goal is achieved. The secrets never leave this crate. // region: Public API constants // ANSI colors for Linux terminal // https://github.com/shiena/ansicolor/blob/master/README.md /// ANSI color pub const RED: &str = "\x1b[31m"; /// ANSI color pub const GREEN: &str = "\x1b[32m"; /// ANSI color pub const YELLOW: &str = "\x1b[33m"; /// ANSI color pub const BLUE: &str = "\x1b[34m"; /// ANSI color pub const RESET: &str = "\x1b[0m"; // endregion: Public API constants pub use cargo_auto_encrypt_secret_lib::EncryptedString; pub use secrecy::ExposeSecret; pub(crate) mod decrypt_mod { use crate::secrets_always_local_mod::*; /// The secrets must not leave this crate. /// They are never going into an external library crate. /// This crate is "user code" and is easy to review and inspect. pub(crate) struct Decryptor<'a> { secret_string: secrecy::SecretString, secret_passcode_bytes: &'a secrecy::SecretVec<u8>, } impl<'a> Decryptor<'a> { pub(crate) fn new_for_decrypt(secret_passcode_bytes: &'a secrecy::SecretVec<u8>) -> Self { Decryptor { secret_string: secrecy::SecretString::new("".to_string()), secret_passcode_bytes, } } pub(crate) fn return_secret_string(&self) -> &secrecy::SecretString { &self.secret_string } /// Decrypts encrypted_string with secret_passcode_bytes /// /// secret_passcode_bytes must be 32 bytes or more /// Returns the secret_string pub(crate) fn decrypt_symmetric(&mut self, encrypted_string: &cargo_auto_encrypt_secret_lib::EncryptedString) { let encrypted_bytes = <base64ct::Base64 as base64ct::Encoding>::decode_vec(&encrypted_string.0).unwrap(); //only first 32 bytes let mut secret_passcode_32bytes = [0u8; 32]; secret_passcode_32bytes.copy_from_slice(&self.secret_passcode_bytes.expose_secret()[0..32]); let cipher = <aes_gcm::Aes256Gcm as aes_gcm::KeyInit>::new(&secret_passcode_32bytes.into()); // nonce is salt let nonce = rsa::sha2::digest::generic_array::GenericArray::from_slice(&encrypted_bytes[..12]); let cipher_text = &encrypted_bytes[12..]; let Ok(decrypted_bytes) = aes_gcm::aead::Aead::decrypt(&cipher, nonce, cipher_text) else { panic!("{RED}Error: Decryption failed. {RESET}"); }; let decrypted_string = String::from_utf8(decrypted_bytes).unwrap(); self.secret_string = secrecy::SecretString::new(decrypted_string) } } } pub(crate) mod encrypt_mod { use crate::secrets_always_local_mod::*; /// The secrets must not leave this crate. /// They are never going into an external library crate. /// This crate is "user code" and is easy to review and inspect. pub(crate) struct Encryptor<'a> { secret_string: secrecy::SecretString, secret_passcode_bytes: &'a secrecy::SecretVec<u8>, } impl<'a> Encryptor<'a> { pub(crate) fn new_for_encrypt(secret_string: secrecy::SecretString, secret_passcode_bytes: &'a secrecy::SecretVec<u8>) -> Self { Encryptor { secret_string, secret_passcode_bytes } } /// Encrypts secret_string with secret_passcode_bytes /// /// secret_passcode_bytes must be 32 bytes or more /// returns the encrypted_string pub(crate) fn encrypt_symmetric(&self) -> Option<cargo_auto_encrypt_secret_lib::EncryptedString> { //only first 32 bytes let mut secret_passcode_32bytes = [0u8; 32]; secret_passcode_32bytes.copy_from_slice(&self.secret_passcode_bytes.expose_secret()[0..32]); let cipher = <aes_gcm::Aes256Gcm as aes_gcm::KeyInit>::new(&secret_passcode_32bytes.into()); // nonce is salt let nonce = <aes_gcm::Aes256Gcm as aes_gcm::AeadCore>::generate_nonce(&mut aes_gcm::aead::OsRng); let Ok(cipher_text) = aes_gcm::aead::Aead::encrypt(&cipher, &nonce, self.secret_string.expose_secret().as_bytes()) else { panic!("{RED}Error: Encryption failed. {RESET}"); }; let mut encrypted_bytes = nonce.to_vec(); encrypted_bytes.extend_from_slice(&cipher_text); let encrypted_string = <base64ct::Base64 as base64ct::Encoding>::encode_string(&encrypted_bytes); Some(cargo_auto_encrypt_secret_lib::EncryptedString(encrypted_string)) } } } pub(crate) mod secrecy_mod { //! The crate secrecy is probably great. //! But I want to encrypt the content, so I will make a wrapper. //! The secrets must always be moved to secrecy types as soon as possible. use crate::secrets_always_local_mod::*; pub struct SecretEncryptedString { encrypted_string: EncryptedString, } impl SecretEncryptedString { pub fn new_with_secret_string(secret_string: secrecy::SecretString, session_passcode: &secrecy::SecretVec<u8>) -> Self { let encryptor = super::encrypt_mod::Encryptor::new_for_encrypt(secret_string, &session_passcode); let encrypted_string = encryptor.encrypt_symmetric().unwrap(); SecretEncryptedString { encrypted_string } } pub fn new_with_string(secret_string: String, session_passcode: &secrecy::SecretVec<u8>) -> Self { let secret_string = secrecy::SecretString::new(secret_string); Self::new_with_secret_string(secret_string, session_passcode) } pub fn expose_decrypted_secret(&self, session_passcode: &secrecy::SecretVec<u8>) -> secrecy::SecretString { let mut decryptor = super::decrypt_mod::Decryptor::new_for_decrypt(&session_passcode); decryptor.decrypt_symmetric(&self.encrypted_string); decryptor.return_secret_string().clone() } } } pub(crate) mod ssh_mod { use crate::secrets_always_local_mod::*; pub struct SshContext { signed_passcode_is_a_secret: secrecy::SecretVec<u8>, decrypted_string: secrecy::SecretString, } impl SshContext { pub fn new() -> Self { SshContext { signed_passcode_is_a_secret: secrecy::SecretVec::new(vec![]), decrypted_string: secrecy::SecretString::new("".to_string()), } } pub fn get_decrypted_string(&self) -> secrecy::SecretString { self.decrypted_string.clone() } } impl cargo_auto_encrypt_secret_lib::SshContextTrait for SshContext { /// decrypt from file data and write the decrypted secret in private field for later use in this crate, not in external library crates fn decrypt_from_file_data(&mut self, encrypted_string: &cargo_auto_encrypt_secret_lib::EncryptedString) { let mut decryptor = decrypt_mod::Decryptor::new_for_decrypt(&self.signed_passcode_is_a_secret); decryptor.decrypt_symmetric(encrypted_string); self.decrypted_string = decryptor.return_secret_string().clone(); } /// get secret_token and encrypt fn get_secret_token_and_encrypt(&self) -> cargo_auto_encrypt_secret_lib::EncryptedString { /// Internal function used only for test configuration /// /// It is not interactive, but reads from a env var. #[cfg(test)] fn get_secret_token() -> secrecy::SecretString { secrecy::SecretString::new(std::env::var("TEST_TOKEN").unwrap()) } /// Internal function get_passphrase interactively ask user to type the passphrase /// /// This is used for normal code execution. #[cfg(not(test))] fn get_secret_token() -> secrecy::SecretString { eprintln!(" "); eprintln!(" {BLUE}Enter the secret_token to encrypt:{RESET}"); secrecy::SecretString::new( inquire::Password::new("") .without_confirmation() .with_display_mode(inquire::PasswordDisplayMode::Masked) .prompt() .unwrap(), ) } let secret_token = get_secret_token(); // use this signed as password for symmetric encryption let encryptor = encrypt_mod::Encryptor::new_for_encrypt(secret_token, &self.signed_passcode_is_a_secret); let encrypted_token = encryptor.encrypt_symmetric().unwrap(); // return encrypted_token } /// Sign with ssh-agent or with identity_file /// /// get passphrase interactively /// returns secret_password_bytes:Vec u8 fn sign_with_ssh_agent_or_identity_file(&mut self, identity_private_file_path: &camino::Utf8Path, seed_bytes_not_a_secret: &[u8; 32]) { /// Internal function used only for test configuration /// /// It is not interactive, but reads from a env var. #[cfg(test)] fn get_passphrase() -> secrecy::SecretString { secrecy::SecretString::new(std::env::var("TEST_PASSPHRASE").unwrap()) } /// Internal function get_passphrase interactively ask user to type the passphrase /// /// This is used for normal code execution. #[cfg(not(test))] fn get_passphrase() -> secrecy::SecretString { eprintln!(" "); eprintln!(" {BLUE}Enter the passphrase for the SSH private key:{RESET}"); secrecy::SecretString::new( inquire::Password::new("") .without_confirmation() .with_display_mode(inquire::PasswordDisplayMode::Masked) .prompt() .unwrap(), ) } let identity_private_file_path_expanded = expand_path_check_private_key_exists(identity_private_file_path); let fingerprint_from_file = cargo_auto_encrypt_secret_lib::get_fingerprint_from_file(&identity_private_file_path_expanded); let mut ssh_agent_client = cargo_auto_encrypt_secret_lib::crate_ssh_agent_client(); match cargo_auto_encrypt_secret_lib::ssh_add_list_contains_fingerprint(&mut ssh_agent_client, &fingerprint_from_file) { Some(public_key) => { // sign with public key from ssh-agent let signature_is_the_new_secret_password = ssh_agent_client.sign(&public_key, seed_bytes_not_a_secret).unwrap(); // only the data part of the signature goes into as_bytes. self.signed_passcode_is_a_secret = secrecy::SecretVec::new(signature_is_the_new_secret_password.as_bytes().to_owned()); } None => { // ask user to think about adding with ssh-add eprintln!(" {YELLOW}SSH key for encrypted secret_token is not found in the ssh-agent.{RESET}"); eprintln!(" {YELLOW}Without ssh-agent, you will have to type the private key passphrase every time. This is more secure, but inconvenient.{RESET}"); eprintln!(" {YELLOW}You can manually add the SSH identity to ssh-agent for 1 hour:{RESET}"); eprintln!(" {YELLOW}WARNING: using ssh-agent is less secure, because there is no need for user interaction.{RESET}"); eprintln!("{GREEN}ssh-add -t 1h {identity_private_file_path_expanded}{RESET}"); // just for test purpose I will use env var to read this passphrase. Don't use it in production. let passphrase_is_a_secret = get_passphrase(); let private_key = ssh_key::PrivateKey::read_openssh_file(identity_private_file_path_expanded.as_std_path()).unwrap(); let mut private_key = private_key.decrypt(passphrase_is_a_secret.expose_secret()).unwrap(); // FYI: this type of signature is compatible with ssh-agent because it does not involve namespace let signature_is_the_new_secret_password = rsa::signature::SignerMut::try_sign(&mut private_key, seed_bytes_not_a_secret).unwrap(); // only the data part of the signature goes into as_bytes. self.signed_passcode_is_a_secret = secrecy::SecretVec::new(signature_is_the_new_secret_password.as_bytes().to_owned()); } } } } /// Expand path and check if identity file exists /// /// Inform the user how to generate identity file. pub fn expand_path_check_private_key_exists(identity_private_file_path: &camino::Utf8Path) -> camino::Utf8PathBuf { let identity_private_file_path_expanded = cargo_auto_encrypt_secret_lib::file_path_home_expand(identity_private_file_path); if !camino::Utf8Path::new(&identity_private_file_path_expanded).exists() { eprintln!("{RED}Identity file {identity_private_file_path_expanded} that contains the SSH private key does not exist! {RESET}"); eprintln!(" {YELLOW}Create the SSH key manually in bash with this command:{RESET}"); if identity_private_file_path_expanded.as_str().contains("github_api") { eprintln!(r#"{GREEN}ssh-keygen -t ed25519 -f "{identity_private_file_path_expanded}" -C "github api secret_token"{RESET}"#); } else if identity_private_file_path_expanded.as_str().contains("crates_io") { eprintln!(r#"{GREEN}ssh-keygen -t ed25519 -f "{identity_private_file_path_expanded}" -C "crates io secret_token"{RESET}"#); } else if identity_private_file_path_expanded.as_str().contains("docker_hub") { eprintln!(r#"{GREEN}ssh-keygen -t ed25519 -f "{identity_private_file_path_expanded}" -C "docker hub secret_token"{RESET}"#); } eprintln!(" "); panic!("{RED}Error: File {identity_private_file_path_expanded} does not exist! {RESET}"); } identity_private_file_path_expanded } } pub(crate) mod docker_hub_mod { //! Push to docker-hub needs the docker hub secret_token. This is a secret important just like a password. //! I don't want to pass this secret to an "obscure" library crate that is difficult to review. //! This secret will stay here in this codebase that every developer can easily inspect. //! Instead of the secret_token, I will pass the struct DockerHubClient with the trait SendToDockerHub. //! This way, the secret_token will be encapsulated. use crate::secrets_always_local_mod::*; use cargo_auto_lib::ShellCommandLimitedDoubleQuotesSanitizer; use cargo_auto_lib::ShellCommandLimitedDoubleQuotesSanitizerTrait; /// Struct DockerHubClient contains only private fields /// This fields are accessible only to methods in implementation of traits. pub struct DockerHubClient { /// Passcode for encrypt the secret_token to encrypted_token in memory. /// So that the secret is in memory as little as possible as plain text. /// For every session (program start) a new random passcode is created. session_passcode: secrecy::SecretVec<u8>, /// private field is set only once in the new() constructor encrypted_token: super::secrecy_mod::SecretEncryptedString, } impl DockerHubClient { /// Create new DockerHub client /// /// Interactively ask the user to input the docker hub secret_token. #[allow(dead_code)] pub fn new_interactive_input_secret_token() -> Self { let mut docker_hub_client = Self::new_wo_secret_token(); println!("{BLUE}Enter the docker hub secret_token:{RESET}"); docker_hub_client.encrypted_token = super::secrecy_mod::SecretEncryptedString::new_with_string(inquire::Password::new("").without_confirmation().prompt().unwrap(), &docker_hub_client.session_passcode); // return docker_hub_client } /// Create new DockerHub client without secret_token #[allow(dead_code)] fn new_wo_secret_token() -> Self { /// Internal function Generate a random password fn random_byte_passcode() -> [u8; 32] { let mut password = [0_u8; 32]; use aes_gcm::aead::rand_core::RngCore; aes_gcm::aead::OsRng.fill_bytes(&mut password); password } let session_passcode = secrecy::SecretVec::new(random_byte_passcode().to_vec()); let encrypted_token = super::secrecy_mod::SecretEncryptedString::new_with_string("".to_string(), &session_passcode); DockerHubClient { session_passcode, encrypted_token } } /// Use the stored docker hub secret_token /// /// If the secret_token not exists ask user to interactively input the secret_token. /// To decrypt it, use the SSH passphrase. That is much easier to type than typing the secret_token. /// It is then possible also to have the ssh key in ssh-agent and write the passphrase only once. /// But this great user experience comes with security concerns. The secret_token is accessible if the attacker is very dedicated. #[allow(dead_code)] pub fn new_with_stored_secret_token(user_name: &str, registry: &str) -> Self { /// Internal function for DRY Don't Repeat Yourself fn read_secret_token_and_decrypt_return_docker_hub_client(mut ssh_context: super::ssh_mod::SshContext, encrypted_string_file_path: &camino::Utf8Path) -> DockerHubClient { cargo_auto_encrypt_secret_lib::decrypt_with_ssh_interactive_from_file(&mut ssh_context, encrypted_string_file_path); let secret_token = ssh_context.get_decrypted_string(); let mut docker_hub_client = DockerHubClient::new_wo_secret_token(); docker_hub_client.encrypted_token = super::secrecy_mod::SecretEncryptedString::new_with_secret_string(secret_token, &docker_hub_client.session_passcode); docker_hub_client } // check if the plain-text file from `podman login` exists and warn the user because it is a security vulnerability. let file_auth = "${XDG_RUNTIME_DIR}/containers/auth.json"; if let Some(xdg_runtime_dir) = std::env::var_os("XDG_RUNTIME_DIR") { let xdg_runtime_dir = xdg_runtime_dir.to_string_lossy().to_string(); let file_auth_expanded = file_auth.replace("${XDG_RUNTIME_DIR}", &xdg_runtime_dir); let file_auth_expanded = camino::Utf8Path::new(&file_auth_expanded); if file_auth_expanded.exists() { eprintln!("{RED}Security vulnerability: Found the docker hub file with plain-text secret_token: {file_auth_expanded}. It would be better to inspect and remove it. {RESET}") } } // registry: docker.io -> replace dot into "--"" // username: bestiadev let registry_escaped = registry.replace(".", "--"); let encrypted_string_file_path = format!("~/.ssh/docker_hub_{registry_escaped}_{user_name}.txt"); let encrypted_string_file_path = camino::Utf8Path::new(&encrypted_string_file_path); let encrypted_string_file_path_expanded = cargo_auto_encrypt_secret_lib::file_path_home_expand(encrypted_string_file_path); let identity_private_file_path = camino::Utf8Path::new("~/.ssh/docker_hub_secret_token_ssh_1"); let _identity_private_file_path_expanded = crate::secrets_always_local_mod::ssh_mod::expand_path_check_private_key_exists(identity_private_file_path); if !encrypted_string_file_path_expanded.exists() { // ask interactive println!(" {BLUE}Do you want to store the docker hub secret_token encrypted with an SSH key? (y/n){RESET}"); let answer = inquire::Text::new("").prompt().unwrap(); if answer.to_lowercase() != "y" { // enter the secret_token manually, not storing return Self::new_interactive_input_secret_token(); } else { // get the passphrase and secret_token interactively let mut ssh_context = super::ssh_mod::SshContext::new(); // encrypt and save the encrypted secret_token cargo_auto_encrypt_secret_lib::encrypt_with_ssh_interactive_save_file(&mut ssh_context, identity_private_file_path, encrypted_string_file_path); // read the secret_token and decrypt, return DockerHubClient read_secret_token_and_decrypt_return_docker_hub_client(ssh_context, encrypted_string_file_path) } } else { // file exists let ssh_context = super::ssh_mod::SshContext::new(); // read the secret_token and decrypt, return DockerHubClient read_secret_token_and_decrypt_return_docker_hub_client(ssh_context, encrypted_string_file_path) } } /// decrypts the secret_token in memory #[allow(dead_code)] pub fn decrypt_secret_token_in_memory(&self) -> secrecy::SecretString { self.encrypted_token.expose_decrypted_secret(&self.session_passcode) } /// Push to docker hub /// /// This function encapsulates the secret docker hub secret_token. /// The client can be passed to the library. It will not reveal the secret_token. #[allow(dead_code)] pub fn push_to_docker_hub(&self, image_url: &str, user_name: &str) { // the secret_token can be used in place of the password in --cred ShellCommandLimitedDoubleQuotesSanitizer::new(r#"podman push --creds "{user_name}:{secret_token}" "{image_url}" "#) .unwrap_or_else(|e| panic!("{e}")) .arg("{user_name}", user_name) .unwrap_or_else(|e| panic!("{e}")) .arg_secret("{secret_token}", &self.decrypt_secret_token_in_memory()) .unwrap_or_else(|e| panic!("{e}")) .arg("{image_url}", image_url) .unwrap_or_else(|e| panic!("{e}")) .run() .unwrap_or_else(|e| panic!("{e}")); } } }