Adding upstream version 0.5.5.
Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
parent
dde4be91ba
commit
d2d6608958
17 changed files with 2615 additions and 0 deletions
110
src/base64_decode.rs
Normal file
110
src/base64_decode.rs
Normal file
|
@ -0,0 +1,110 @@
|
|||
/// An error that can occur during base64 decoding.
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub enum Error {
|
||||
InvalidBase64Char(u8),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::InvalidBase64Char(value) => write!(f, "Invalid base64 character: {:?}", char::from_u32(*value as u32).unwrap()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a base64 string.
|
||||
///
|
||||
/// Padding in the input is optional.
|
||||
pub fn base64_decode(input: &[u8]) -> Result<Vec<u8>, Error> {
|
||||
let input = match input.iter().rposition(|&byte| byte != b'=' && !byte.is_ascii_whitespace()) {
|
||||
Some(x) => &input[..=x],
|
||||
None => return Ok(Vec::new()),
|
||||
};
|
||||
|
||||
let mut output = Vec::with_capacity((input.len() + 3) / 4 * 3);
|
||||
let mut decoder = Base64Decoder::new();
|
||||
|
||||
for &byte in input {
|
||||
if byte.is_ascii_whitespace() {
|
||||
continue;
|
||||
}
|
||||
if let Some(byte) = decoder.feed(byte)? {
|
||||
output.push(byte);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Get the 6 bit value for a base64 character.
|
||||
fn base64_value(byte: u8) -> Result<u8, Error> {
|
||||
match byte {
|
||||
b'A'..=b'Z' => Ok(byte - b'A'),
|
||||
b'a'..=b'z' => Ok(byte - b'a' + 26),
|
||||
b'0'..=b'9' => Ok(byte - b'0' + 52),
|
||||
b'+' => Ok(62),
|
||||
b'/' => Ok(63),
|
||||
byte => Err(Error::InvalidBase64Char(byte)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Decoder for base64 data.
|
||||
struct Base64Decoder {
|
||||
/// The current buffer.
|
||||
buffer: u16,
|
||||
|
||||
/// The number of valid bits in the buffer.
|
||||
valid_bits: u8,
|
||||
}
|
||||
|
||||
impl Base64Decoder {
|
||||
/// Create a new base64 decoder.
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
buffer: 0,
|
||||
valid_bits: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed a base64 character to the decoder.
|
||||
///
|
||||
/// Returns `Ok(Some(u8))` if a new character is fully decoded.
|
||||
/// Returns `Ok(None)` if there is no new character available yet.
|
||||
fn feed(&mut self, byte: u8) -> Result<Option<u8>, Error> {
|
||||
debug_assert!(self.valid_bits < 8);
|
||||
// Paste the new 6 bit value at the least significant position in the buffer.
|
||||
self.buffer |= (base64_value(byte)? as u16) << (10 - self.valid_bits);
|
||||
// Bump the number of valid bits.
|
||||
self.valid_bits += 6;
|
||||
// Consume the most significant byte if it is complete.
|
||||
Ok(self.consume_buffer_front())
|
||||
}
|
||||
|
||||
/// Consume the first character in the buffer.
|
||||
fn consume_buffer_front(&mut self) -> Option<u8> {
|
||||
if self.valid_bits >= 8 {
|
||||
let value = self.buffer >> 8 & 0xFF;
|
||||
self.buffer <<= 8;
|
||||
self.valid_bits -= 8;
|
||||
Some(value as u8)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use assert2::assert;
|
||||
|
||||
#[test]
|
||||
fn test_decode_base64() {
|
||||
assert!(let Ok(b"0") = base64_decode(b"MA").as_deref());
|
||||
assert!(let Ok(b"0") = base64_decode(b"MA=").as_deref());
|
||||
assert!(let Ok(b"0") = base64_decode(b"MA==").as_deref());
|
||||
assert!(let Ok(b"aap noot mies") = base64_decode(b"YWFwIG5vb3QgbWllcw").as_deref());
|
||||
assert!(let Ok(b"aap noot mies") = base64_decode(b"YWFwIG5vb3QgbWllcw=").as_deref());
|
||||
assert!(let Ok(b"aap noot mies") = base64_decode(b"YWFwIG5vb3QgbWllcw==").as_deref());
|
||||
}
|
||||
}
|
183
src/default_prompt.rs
Normal file
183
src/default_prompt.rs
Normal file
|
@ -0,0 +1,183 @@
|
|||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[cfg(feature = "log")]
|
||||
use crate::log::*;
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
pub(crate) struct DefaultPrompter;
|
||||
|
||||
impl crate::Prompter for DefaultPrompter {
|
||||
fn prompt_username_password(&mut self, url: &str, git_config: &git2::Config) -> Option<(String, String)> {
|
||||
prompt_username_password(url, git_config)
|
||||
.map_err(|e| log_error("username and password", &e))
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn prompt_password(&mut self, username: &str, url: &str, git_config: &git2::Config) -> Option<String> {
|
||||
prompt_password(username, url, git_config)
|
||||
.map_err(|e| log_error("password", &e))
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn prompt_ssh_key_passphrase(&mut self, private_key_path: &Path, git_config: &git2::Config) -> Option<String> {
|
||||
prompt_ssh_key_passphrase(private_key_path, git_config)
|
||||
.map_err(|e| log_error("SSH key passphrase", &e))
|
||||
.ok()
|
||||
}
|
||||
}
|
||||
|
||||
fn log_error(kind: &str, error: &Error) {
|
||||
warn!("Failed to prompt the user for {kind}: {error}");
|
||||
if let Error::AskpassExitStatus(error) = error {
|
||||
if let Some(extra_message) = error.extra_message() {
|
||||
for line in extra_message.lines() {
|
||||
warn!("askpass: {line}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Error that can occur when prompting for a password.
|
||||
pub enum Error {
|
||||
/// Failed to run the askpass command.
|
||||
AskpassCommand(std::io::Error),
|
||||
|
||||
/// Askpass command exitted with a non-zero error code.
|
||||
AskpassExitStatus(AskpassExitStatusError),
|
||||
|
||||
/// Password contains invalid UTF-8.
|
||||
InvalidUtf8(std::string::FromUtf8Error),
|
||||
|
||||
/// Failed to open a handle to the main terminal of the process.
|
||||
OpenTerminal(std::io::Error),
|
||||
|
||||
/// Failed to read/write to the terminal.
|
||||
ReadWriteTerminal(std::io::Error),
|
||||
}
|
||||
|
||||
/// The askpass process exited with a non-zero exit code.
|
||||
pub struct AskpassExitStatusError {
|
||||
/// The exit status of the askpass process.
|
||||
pub status: std::process::ExitStatus,
|
||||
|
||||
/// The standard error of the askpass process.
|
||||
pub stderr: Result<String, std::string::FromUtf8Error>,
|
||||
}
|
||||
|
||||
impl AskpassExitStatusError {
|
||||
/// Get the extra error message, if any.
|
||||
///
|
||||
/// This will give the standard error of the askpass process if it exited with an error.
|
||||
pub fn extra_message(&self) -> Option<&str> {
|
||||
self.stderr.as_deref().ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// Prompt the user for a username and password for a particular URL.
|
||||
///
|
||||
/// This uses the askpass helper if configured,
|
||||
/// and falls back to prompting on the terminal otherwise.
|
||||
fn prompt_username_password(url: &str, git_config: &git2::Config) -> Result<(String, String), Error> {
|
||||
if let Some(askpass) = askpass_command(git_config) {
|
||||
let username = askpass_prompt(&askpass, &format!("Username for {url}"))?;
|
||||
let password = askpass_prompt(&askpass, &format!("Password for {url}"))?;
|
||||
Ok((username, password))
|
||||
} else {
|
||||
let mut terminal = terminal_prompt::Terminal::open()
|
||||
.map_err(Error::OpenTerminal)?;
|
||||
writeln!(terminal, "Authentication needed for {url}")
|
||||
.map_err(Error::ReadWriteTerminal)?;
|
||||
let username = terminal.prompt("Username: ")
|
||||
.map_err(Error::ReadWriteTerminal)?;
|
||||
let password = terminal.prompt_sensitive("Password: ")
|
||||
.map_err(Error::ReadWriteTerminal)?;
|
||||
Ok((username, password))
|
||||
}
|
||||
}
|
||||
|
||||
/// Prompt the user for a password for a particular URL and username.
|
||||
///
|
||||
/// This uses the askpass helper if configured,
|
||||
/// and falls back to prompting on the terminal otherwise.
|
||||
fn prompt_password(_username: &str, url: &str, git_config: &git2::Config) -> Result<String, Error> {
|
||||
if let Some(askpass) = askpass_command(git_config) {
|
||||
let password = askpass_prompt(&askpass, &format!("Password for {url}"))?;
|
||||
Ok(password)
|
||||
} else {
|
||||
let mut terminal = terminal_prompt::Terminal::open()
|
||||
.map_err(Error::OpenTerminal)?;
|
||||
writeln!(terminal, "Authentication needed for {url}")
|
||||
.map_err(Error::ReadWriteTerminal)?;
|
||||
let password = terminal.prompt_sensitive("Password: ")
|
||||
.map_err(Error::ReadWriteTerminal)?;
|
||||
Ok(password)
|
||||
}
|
||||
}
|
||||
|
||||
/// Prompt the user for the password of an encrypted SSH key.
|
||||
///
|
||||
/// This uses the askpass helper if configured,
|
||||
/// and falls back to prompting on the terminal otherwise.
|
||||
fn prompt_ssh_key_passphrase(private_key_path: &Path, git_config: &git2::Config) -> Result<String, Error> {
|
||||
if let Some(askpass) = askpass_command(git_config) {
|
||||
askpass_prompt(&askpass, &format!("Password for {}", private_key_path.display()))
|
||||
} else {
|
||||
let mut terminal = terminal_prompt::Terminal::open()
|
||||
.map_err(Error::OpenTerminal)?;
|
||||
writeln!(terminal, "Password needed for {}", private_key_path.display())
|
||||
.map_err(Error::ReadWriteTerminal)?;
|
||||
terminal.prompt_sensitive("Password: ")
|
||||
.map_err(Error::ReadWriteTerminal)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the configured askpass program, if any.
|
||||
fn askpass_command(git_config: &git2::Config) -> Option<PathBuf> {
|
||||
if let Some(command) = std::env::var_os("GIT_ASKPASS") {
|
||||
Some(command.into())
|
||||
} else if let Ok(command) = git_config.get_path("core.askPass") {
|
||||
return Some(command)
|
||||
} else if let Some(command) = std::env::var_os("SSH_ASKPASS") {
|
||||
return Some(command.into());
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Prompt the user using the given askpass program.
|
||||
fn askpass_prompt(program: &Path, prompt: &str) -> Result<String, Error> {
|
||||
let output = std::process::Command::new(program)
|
||||
.arg(prompt)
|
||||
.output()
|
||||
.map_err(Error::AskpassCommand)?;
|
||||
if output.status.success() {
|
||||
let password = String::from_utf8(output.stdout)
|
||||
.map_err(Error::InvalidUtf8)?;
|
||||
Ok(password)
|
||||
} else {
|
||||
// Do not keep stdout, it could contain a password D:
|
||||
Err(Error::AskpassExitStatus(AskpassExitStatusError {
|
||||
status: output.status,
|
||||
stderr: String::from_utf8(output.stderr),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::AskpassCommand(e) => write!(f, "Failed to run askpass command: {e}"),
|
||||
Self::AskpassExitStatus(e) => write!(f, "{e}"),
|
||||
Self::InvalidUtf8(_) => write!(f, "User response contains invalid UTF-8"),
|
||||
Self::OpenTerminal(e) => write!(f, "Failed to open terminal: {e}"),
|
||||
Self::ReadWriteTerminal(e) => write!(f, "Failed to read/write to terminal: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AskpassExitStatusError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Program exitted with {}", self.status)
|
||||
}
|
||||
}
|
719
src/lib.rs
Normal file
719
src/lib.rs
Normal file
|
@ -0,0 +1,719 @@
|
|||
//! Easy authentication for [`git2`].
|
||||
//!
|
||||
//! Authentication with [`git2`] can be quite difficult to implement correctly.
|
||||
//! This crate aims to make it easy.
|
||||
//!
|
||||
//! # Features
|
||||
//!
|
||||
//! * Has a small dependency tree.
|
||||
//! * Can query the SSH agent for private key authentication.
|
||||
//! * Can get SSH keys from files.
|
||||
//! * Can prompt the user for passwords for encrypted SSH keys.
|
||||
//! * Only supported for OpenSSH private keys.
|
||||
//! * Can query the git credential helper for usernames and passwords.
|
||||
//! * Can use pre-provided plain usernames and passwords.
|
||||
//! * Can prompt the user for credentials as a last resort.
|
||||
//! * Allows you to fully customize all user prompts.
|
||||
//!
|
||||
//! The default user prompts will:
|
||||
//! * Use the git `askpass` helper if it is configured.
|
||||
//! * Fall back to prompting the user on the terminal if there is no `askpass` program configured.
|
||||
//! * Skip the prompt if there is also no terminal available for the process.
|
||||
//!
|
||||
//! # Creating an authenticator and enabling authentication mechanisms
|
||||
//!
|
||||
//! You can create use [`GitAuthenticator::new()`] (or [`default()`][`GitAuthenticator::default()`]) to create a ready-to-use authenticator.
|
||||
//! Using one of these constructors will enable all supported authentication mechanisms.
|
||||
//! You can still add more private key files from non-default locations to try if desired.
|
||||
//!
|
||||
//! You can also use [`GitAuthenticator::new_empty()`] to create an authenticator without any authentication mechanism enabled.
|
||||
//! Then you can selectively enable authentication mechanisms and add custom private key files.
|
||||
//!
|
||||
//! # Using the authenticator
|
||||
//!
|
||||
//! For the most flexibility, you can get a [`git2::Credentials`] callback using the [`GitAuthenticator::credentials()`] function.
|
||||
//! You can use it with any git operation that requires authentication.
|
||||
//! Doing this gives you full control to set other options and callbacks for the git operation.
|
||||
//!
|
||||
//! If you don't need to set other options or callbacks, you can also use the convenience functions on [`GitAuthenticator`].
|
||||
//! They wrap git operations with the credentials callback set:
|
||||
//!
|
||||
//! * [`GitAuthenticator::clone_repo()`]
|
||||
//! * [`GitAuthenticator::fetch()`]
|
||||
//! * [`GitAuthenticator::download()`]
|
||||
//! * [`GitAuthenticator::push()`]
|
||||
//!
|
||||
//! # Customizing user prompts
|
||||
//!
|
||||
//! All user prompts can be fully customized by calling [`GitAuthenticator::set_prompter()`].
|
||||
//! This allows you to override the way that the user is prompted for credentials or passphrases.
|
||||
//!
|
||||
//! If you have a fancy user interface, you can use a custom prompter to integrate the prompts with your user interface.
|
||||
//!
|
||||
//! # Example: Clone a repository
|
||||
//!
|
||||
//! ```no_run
|
||||
//! # fn main() -> Result<(), git2::Error> {
|
||||
//! use auth_git2::GitAuthenticator;
|
||||
//! use std::path::Path;
|
||||
//!
|
||||
//! let url = "https://github.com/de-vri-es/auth-git2-rs";
|
||||
//! let into = Path::new("/tmp/dyfhxoaj/auth-git2-rs");
|
||||
//!
|
||||
//! let auth = GitAuthenticator::default();
|
||||
//! let mut repo = auth.clone_repo(url, into);
|
||||
//! # let _ = repo;
|
||||
//! # Ok(())
|
||||
//! # }
|
||||
//! ```
|
||||
//!
|
||||
//! # Example: Clone a repository with full control over fetch options
|
||||
//!
|
||||
//! ```no_run
|
||||
//! # fn main() -> Result<(), git2::Error> {
|
||||
//! use auth_git2::GitAuthenticator;
|
||||
//! use std::path::Path;
|
||||
//!
|
||||
//! let auth = GitAuthenticator::default();
|
||||
//! let git_config = git2::Config::open_default()?;
|
||||
//! let mut repo_builder = git2::build::RepoBuilder::new();
|
||||
//! let mut fetch_options = git2::FetchOptions::new();
|
||||
//! let mut remote_callbacks = git2::RemoteCallbacks::new();
|
||||
//!
|
||||
//! remote_callbacks.credentials(auth.credentials(&git_config));
|
||||
//! fetch_options.remote_callbacks(remote_callbacks);
|
||||
//! repo_builder.fetch_options(fetch_options);
|
||||
//!
|
||||
//! let url = "https://github.com/de-vri-es/auth-git2-rs";
|
||||
//! let into = Path::new("/tmp/dyfhxoaj/auth-git2-rs");
|
||||
//! let mut repo = repo_builder.clone(url, into);
|
||||
//! # let _ = repo;
|
||||
//! # Ok(())
|
||||
//! # }
|
||||
//! ```
|
||||
|
||||
#![warn(missing_docs)]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{PathBuf, Path};
|
||||
|
||||
#[cfg(feature = "log")]
|
||||
mod log {
|
||||
pub use ::log::warn;
|
||||
pub use ::log::debug;
|
||||
pub use ::log::trace;
|
||||
}
|
||||
|
||||
#[cfg(feature = "log")]
|
||||
use crate::log::*;
|
||||
|
||||
#[cfg(not(feature = "log"))]
|
||||
#[macro_use]
|
||||
mod log {
|
||||
macro_rules! warn {
|
||||
($($tokens:tt)*) => { { let _ = format_args!($($tokens)*); } };
|
||||
}
|
||||
|
||||
macro_rules! debug {
|
||||
($($tokens:tt)*) => { { let _ = format_args!($($tokens)*); } };
|
||||
}
|
||||
|
||||
macro_rules! trace {
|
||||
($($tokens:tt)*) => { { let _ = format_args!($($tokens)*); } };
|
||||
}
|
||||
}
|
||||
|
||||
mod base64_decode;
|
||||
mod default_prompt;
|
||||
mod prompter;
|
||||
mod ssh_key;
|
||||
|
||||
pub use prompter::Prompter;
|
||||
|
||||
/// Configurable authenticator to use with [`git2`].
|
||||
#[derive(Clone)]
|
||||
pub struct GitAuthenticator {
|
||||
/// Map of domain names to plaintext credentials.
|
||||
plaintext_credentials: BTreeMap<String, PlaintextCredentials>,
|
||||
|
||||
/// Try getting username/password from the git credential helper.
|
||||
try_cred_helper: bool,
|
||||
|
||||
/// Number of times to ask the user for a username/password on the terminal.
|
||||
try_password_prompt: u32,
|
||||
|
||||
/// Map of domain names to usernames to try for SSH connections if no username was specified.
|
||||
usernames: BTreeMap<String, String>,
|
||||
|
||||
/// Try to use the SSH agent to get a working SSH key.
|
||||
try_ssh_agent: bool,
|
||||
|
||||
/// SSH keys to use from file.
|
||||
ssh_keys: Vec<PrivateKeyFile>,
|
||||
|
||||
/// Prompt for passwords for encrypted SSH keys.
|
||||
prompt_ssh_key_password: bool,
|
||||
|
||||
/// Custom prompter to use.
|
||||
prompter: Box<dyn prompter::ClonePrompter>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for GitAuthenticator {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("GitAuthenticator")
|
||||
.field("plaintext_credentials", &self.plaintext_credentials)
|
||||
.field("try_cred_helper", &self.try_cred_helper)
|
||||
.field("try_password_prompt", &self.try_password_prompt)
|
||||
.field("usernames", &self.usernames)
|
||||
.field("try_ssh_agent", &self.try_ssh_agent)
|
||||
.field("ssh_keys", &self.ssh_keys)
|
||||
.field("prompt_ssh_key_password", &self.prompt_ssh_key_password)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for GitAuthenticator {
|
||||
/// Create a new authenticator with all supported options enabled.
|
||||
///
|
||||
/// This is the same as [`GitAuthenticator::new()`].
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl GitAuthenticator {
|
||||
/// Create a new authenticator with all supported options enabled.
|
||||
///
|
||||
/// This is equivalent to:
|
||||
/// ```
|
||||
/// # use auth_git2::GitAuthenticator;
|
||||
/// GitAuthenticator::new_empty()
|
||||
/// .try_cred_helper(true)
|
||||
/// .try_password_prompt(3)
|
||||
/// .add_default_username()
|
||||
/// .try_ssh_agent(true)
|
||||
/// .add_default_ssh_keys()
|
||||
/// .prompt_ssh_key_password(true)
|
||||
/// # ;
|
||||
/// ```
|
||||
pub fn new() -> Self {
|
||||
Self::new_empty()
|
||||
.try_cred_helper(true)
|
||||
.try_password_prompt(3)
|
||||
.add_default_username()
|
||||
.try_ssh_agent(true)
|
||||
.add_default_ssh_keys()
|
||||
.prompt_ssh_key_password(true)
|
||||
}
|
||||
|
||||
/// Create a new authenticator with all authentication options disabled.
|
||||
pub fn new_empty() -> Self {
|
||||
Self {
|
||||
try_ssh_agent: false,
|
||||
try_cred_helper: false,
|
||||
plaintext_credentials: BTreeMap::new(),
|
||||
try_password_prompt: 0,
|
||||
usernames: BTreeMap::new(),
|
||||
ssh_keys: Vec::new(),
|
||||
prompt_ssh_key_password: false,
|
||||
prompter: prompter::wrap_prompter(default_prompt::DefaultPrompter),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the username + password to use for a specific domain.
|
||||
///
|
||||
/// Use the special value "*" for the domain name to add fallback credentials when there is no exact match for the domain.
|
||||
pub fn add_plaintext_credentials(mut self, domain: impl Into<String>, username: impl Into<String>, password: impl Into<String>) -> Self {
|
||||
let domain = domain.into();
|
||||
let username = username.into();
|
||||
let password = password.into();
|
||||
self.plaintext_credentials.insert(domain, PlaintextCredentials {
|
||||
username,
|
||||
password,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// Configure if the git credentials helper should be used.
|
||||
///
|
||||
/// See the git documentation of the `credential.helper` configuration options for more details.
|
||||
pub fn try_cred_helper(mut self, enable: bool) -> Self {
|
||||
self.try_cred_helper = enable;
|
||||
self
|
||||
}
|
||||
|
||||
/// Configure the number of times we should prompt the user for a username/password.
|
||||
///
|
||||
/// Setting this value to `0` disables password prompts.
|
||||
///
|
||||
/// By default, if an `askpass` helper is configured, it will be used for the prompts.
|
||||
/// Otherwise, the user will be prompted directly on the terminal of the current process.
|
||||
/// If there is also no terminal available, the prompt is skipped.
|
||||
///
|
||||
/// An `askpass` helper can be configured in the `GIT_ASKPASS` environment variable,
|
||||
/// the `core.askPass` configuration value or the `SSH_ASKPASS` environment variable.
|
||||
///
|
||||
/// You can override the prompt behaviour by calling [`Self::set_prompter()`].
|
||||
pub fn try_password_prompt(mut self, max_count: u32) -> Self {
|
||||
self.try_password_prompt = max_count;
|
||||
self
|
||||
}
|
||||
|
||||
/// Use a custom [`Prompter`] to prompt the user for credentials and passphrases.
|
||||
///
|
||||
/// If you set a custom prompter,
|
||||
/// the authenticator will no longer try to use the `askpass` helper or prompt the user on the terminal.
|
||||
/// Instead, the provided prompter will be called.
|
||||
///
|
||||
/// Note that prompts must still be enabled with [`Self::try_password_prompt()`] and [`Self::prompt_ssh_key_password()`].
|
||||
/// If prompts are disabled, your custom prompter will not be called.
|
||||
///
|
||||
/// You can use this function to integrate the prompts with your own user interface
|
||||
/// or simply to tweak the way the user is prompted on the terminal.
|
||||
///
|
||||
/// A unique clone of the prompter will be used for each [`git2::Credentials`] callback returned by [`Self::credentials()`].
|
||||
pub fn set_prompter<P: Prompter + Clone + Send + 'static>(mut self, prompter: P) -> Self {
|
||||
self.prompter = prompter::wrap_prompter(prompter);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a username to try for authentication for a specific domain.
|
||||
///
|
||||
/// Some authentication mechanisms need a username, but not all valid git URLs specify one.
|
||||
/// You can add one or more usernames to try in that situation.
|
||||
///
|
||||
/// You can use the special domain name "*" to set a fallback username for domains that do not have a specific username set.
|
||||
pub fn add_username(mut self, domain: impl Into<String>, username: impl Into<String>) -> Self {
|
||||
let domain = domain.into();
|
||||
let username = username.into();
|
||||
self.usernames.insert(domain, username);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add the default username to try.
|
||||
///
|
||||
/// The default username if read from the `USER` or `USERNAME` environment variable.
|
||||
pub fn add_default_username(self) -> Self {
|
||||
if let Ok(username) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) {
|
||||
self.add_username("*", username)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Configure if the SSH agent should be used for public key authentication.
|
||||
pub fn try_ssh_agent(mut self, enable: bool) -> Self {
|
||||
self.try_ssh_agent = enable;
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a private key to use for public key authentication.
|
||||
///
|
||||
/// The key will be read from disk by `git2`, so it must still exist when the authentication is performed.
|
||||
///
|
||||
/// You can provide a password for decryption of the private key.
|
||||
/// If no password is provided and the `Self::prompt_ssh_key_password()` is enabled,
|
||||
/// the user will be prompted for the passphrase of encrypted keys.
|
||||
/// Note that currently only the `OpenSSH` private key format is supported for detecting that a key is encrypted.
|
||||
///
|
||||
/// A matching `.pub` file will also be read if it exists.
|
||||
/// For example, if you add the private key `"foo/my_ssh_id"`,
|
||||
/// then `"foo/my_ssh_id.pub"` will be used too, if it exists.
|
||||
pub fn add_ssh_key_from_file(mut self, private_key: impl Into<PathBuf>, password: impl Into<Option<String>>) -> Self {
|
||||
let private_key = private_key.into();
|
||||
let public_key = get_pub_key_path(&private_key);
|
||||
let password = password.into();
|
||||
self.ssh_keys.push(PrivateKeyFile {
|
||||
private_key,
|
||||
public_key,
|
||||
password,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// Add all default SSH keys for public key authentication.
|
||||
///
|
||||
/// This will add all of the following files, if they exist:
|
||||
///
|
||||
/// * `"$HOME/.ssh/id_rsa"`
|
||||
/// * `"$HOME/.ssh/id_ecdsa"`
|
||||
/// * `"$HOME/.ssh/id_ecdsa_sk"`
|
||||
/// * `"$HOME/.ssh/id_ed25519"`
|
||||
/// * `"$HOME/.ssh/id_ed25519_sk"`
|
||||
/// * `"$HOME/.ssh/id_dsa"`
|
||||
pub fn add_default_ssh_keys(mut self) -> Self {
|
||||
let ssh_dir = match dirs::home_dir() {
|
||||
Some(x) => x.join(".ssh"),
|
||||
None => return self,
|
||||
};
|
||||
|
||||
let candidates = [
|
||||
"id_rsa",
|
||||
"id_ecdsa,",
|
||||
"id_ecdsa_sk",
|
||||
"id_ed25519",
|
||||
"id_ed25519_sk",
|
||||
"id_dsa",
|
||||
];
|
||||
|
||||
for candidate in candidates {
|
||||
let private_key = ssh_dir.join(candidate);
|
||||
if !private_key.is_file() {
|
||||
continue;
|
||||
}
|
||||
self = self.add_ssh_key_from_file(private_key, None);
|
||||
}
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Prompt for passwords for encrypted SSH keys if needed.
|
||||
///
|
||||
/// By default, if an `askpass` helper is configured, it will be used for the prompts.
|
||||
/// Otherwise, the user will be prompted directly on the terminal of the current process.
|
||||
/// If there is also no terminal available, the prompt is skipped.
|
||||
///
|
||||
/// An `askpass` helper can be configured in the `GIT_ASKPASS` environment variable,
|
||||
/// the `core.askPass` configuration value or the `SSH_ASKPASS` environment variable.
|
||||
///
|
||||
/// You can override the prompt behaviour by calling [`Self::set_prompter()`].
|
||||
pub fn prompt_ssh_key_password(mut self, enable: bool) -> Self {
|
||||
self.prompt_ssh_key_password = enable;
|
||||
self
|
||||
}
|
||||
|
||||
/// Get the credentials callback to use for [`git2::Credentials`].
|
||||
///
|
||||
/// # Example: Fetch from a remote with authentication
|
||||
/// ```no_run
|
||||
/// # fn foo(repo: &mut git2::Repository) -> Result<(), git2::Error> {
|
||||
/// use auth_git2::GitAuthenticator;
|
||||
///
|
||||
/// let auth = GitAuthenticator::default();
|
||||
/// let git_config = repo.config()?;
|
||||
/// let mut fetch_options = git2::FetchOptions::new();
|
||||
/// let mut remote_callbacks = git2::RemoteCallbacks::new();
|
||||
///
|
||||
/// remote_callbacks.credentials(auth.credentials(&git_config));
|
||||
/// fetch_options.remote_callbacks(remote_callbacks);
|
||||
///
|
||||
/// repo.find_remote("origin")?
|
||||
/// .fetch(&["main"], Some(&mut fetch_options), None)?;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
pub fn credentials<'a>(
|
||||
&'a self,
|
||||
git_config: &'a git2::Config,
|
||||
) -> impl 'a + FnMut(&str, Option<&str>, git2::CredentialType) -> Result<git2::Cred, git2::Error> {
|
||||
make_credentials_callback(self, git_config)
|
||||
}
|
||||
|
||||
/// Clone a repository using the git authenticator.
|
||||
///
|
||||
/// If you need more control over the clone options,
|
||||
/// use [`Self::credentials()`] with a [`git2::build::RepoBuilder`].
|
||||
pub fn clone_repo(&self, url: impl AsRef<str>, into: impl AsRef<Path>) -> Result<git2::Repository, git2::Error> {
|
||||
let url = url.as_ref();
|
||||
let into = into.as_ref();
|
||||
|
||||
let git_config = git2::Config::open_default()?;
|
||||
let mut repo_builder = git2::build::RepoBuilder::new();
|
||||
let mut fetch_options = git2::FetchOptions::new();
|
||||
let mut remote_callbacks = git2::RemoteCallbacks::new();
|
||||
|
||||
remote_callbacks.credentials(self.credentials(&git_config));
|
||||
fetch_options.remote_callbacks(remote_callbacks);
|
||||
repo_builder.fetch_options(fetch_options);
|
||||
|
||||
repo_builder.clone(url, into)
|
||||
}
|
||||
|
||||
|
||||
/// Fetch from a remote using the git authenticator.
|
||||
///
|
||||
/// If you need more control over the fetch options,
|
||||
/// use [`Self::credentials()`] with [`git2::Remote::fetch()`].
|
||||
pub fn fetch(&self, repo: &git2::Repository, remote: &mut git2::Remote, refspecs: &[&str], reflog_msg: Option<&str>) -> Result<(), git2::Error> {
|
||||
let git_config = repo.config()?;
|
||||
let mut fetch_options = git2::FetchOptions::new();
|
||||
let mut remote_callbacks = git2::RemoteCallbacks::new();
|
||||
|
||||
remote_callbacks.credentials(self.credentials(&git_config));
|
||||
fetch_options.remote_callbacks(remote_callbacks);
|
||||
remote.fetch(refspecs, Some(&mut fetch_options), reflog_msg)
|
||||
}
|
||||
|
||||
/// Download and index the packfile from a remote using the git authenticator.
|
||||
///
|
||||
/// If you need more control over the download options,
|
||||
/// use [`Self::credentials()`] with [`git2::Remote::download()`].
|
||||
///
|
||||
/// This function does not update the remote tracking branches.
|
||||
/// Consider using [`Self::fetch()`] if that is what you want.
|
||||
pub fn download(&self, repo: &git2::Repository, remote: &mut git2::Remote, refspecs: &[&str]) -> Result<(), git2::Error> {
|
||||
let git_config = repo.config()?;
|
||||
let mut fetch_options = git2::FetchOptions::new();
|
||||
let mut remote_callbacks = git2::RemoteCallbacks::new();
|
||||
|
||||
remote_callbacks.credentials(self.credentials(&git_config));
|
||||
fetch_options.remote_callbacks(remote_callbacks);
|
||||
remote.download(refspecs, Some(&mut fetch_options))
|
||||
}
|
||||
|
||||
/// Push to a remote using the git authenticator.
|
||||
///
|
||||
/// If you need more control over the push options,
|
||||
/// use [`Self::credentials()`] with [`git2::Remote::push()`].
|
||||
pub fn push(&self, repo: &git2::Repository, remote: &mut git2::Remote, refspecs: &[&str]) -> Result<(), git2::Error> {
|
||||
let git_config = repo.config()?;
|
||||
let mut push_options = git2::PushOptions::new();
|
||||
let mut remote_callbacks = git2::RemoteCallbacks::new();
|
||||
|
||||
remote_callbacks.credentials(self.credentials(&git_config));
|
||||
push_options.remote_callbacks(remote_callbacks);
|
||||
|
||||
remote.push(refspecs, Some(&mut push_options))
|
||||
}
|
||||
|
||||
/// Get the configured username for a URL.
|
||||
fn get_username(&self, url: &str) -> Option<&str> {
|
||||
if let Some(domain) = domain_from_url(url) {
|
||||
if let Some(username) = self.usernames.get(domain) {
|
||||
return Some(username);
|
||||
}
|
||||
}
|
||||
self.usernames.get("*").map(|x| x.as_str())
|
||||
}
|
||||
|
||||
/// Get the configured plaintext credentials for a URL.
|
||||
fn get_plaintext_credentials(&self, url: &str) -> Option<&PlaintextCredentials> {
|
||||
if let Some(domain) = domain_from_url(url) {
|
||||
if let Some(credentials) = self.plaintext_credentials.get(domain) {
|
||||
return Some(credentials);
|
||||
}
|
||||
}
|
||||
self.plaintext_credentials.get("*")
|
||||
}
|
||||
}
|
||||
|
||||
fn make_credentials_callback<'a>(
|
||||
authenticator: &'a GitAuthenticator,
|
||||
git_config: &'a git2::Config,
|
||||
) -> impl 'a + FnMut(&str, Option<&str>, git2::CredentialType) -> Result<git2::Cred, git2::Error> {
|
||||
let mut try_cred_helper = authenticator.try_cred_helper;
|
||||
let mut try_password_prompt = authenticator.try_password_prompt;
|
||||
let mut try_ssh_agent = authenticator.try_ssh_agent;
|
||||
let mut ssh_keys = authenticator.ssh_keys.iter();
|
||||
let mut prompter = authenticator.prompter.clone();
|
||||
|
||||
move |url: &str, username: Option<&str>, allowed: git2::CredentialType| {
|
||||
trace!("credentials callback called with url: {url:?}, username: {username:?}, allowed_credentials: {allowed:?}");
|
||||
|
||||
// If git2 is asking for a username, we got an SSH url without username specified.
|
||||
// After we supply a username, it will ask for the real credentials.
|
||||
//
|
||||
// Sadly, we can not switch usernames during an authentication session,
|
||||
// so to try different usernames, we need to retry the git operation multiple times.
|
||||
// If this happens, we'll bail and go into stage 2.
|
||||
if allowed.contains(git2::CredentialType::USERNAME) {
|
||||
if let Some(username) = authenticator.get_username(url) {
|
||||
debug!("credentials_callback: returning username: {username:?}");
|
||||
match git2::Cred::username(username) {
|
||||
Ok(x) => return Ok(x),
|
||||
Err(e) => {
|
||||
debug!("credentials_callback: failed to wrap username: {e}");
|
||||
return Err(e);
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try public key authentication.
|
||||
if allowed.contains(git2::CredentialType::SSH_KEY) {
|
||||
if let Some(username) = username {
|
||||
if try_ssh_agent {
|
||||
try_ssh_agent = false;
|
||||
debug!("credentials_callback: trying ssh_key_from_agent with username: {username:?}");
|
||||
match git2::Cred::ssh_key_from_agent(username) {
|
||||
Ok(x) => return Ok(x),
|
||||
Err(e) => debug!("credentials_callback: failed to use SSH agent: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::while_let_on_iterator)] // Incorrect lint: we're not consuming the iterator.
|
||||
while let Some(key) = ssh_keys.next() {
|
||||
debug!("credentials_callback: trying ssh key, username: {username:?}, private key: {:?}", key.private_key);
|
||||
let prompter = Some(prompter.as_prompter_mut())
|
||||
.filter(|_| authenticator.prompt_ssh_key_password);
|
||||
match key.to_credentials(username, prompter, git_config) {
|
||||
Ok(x) => return Ok(x),
|
||||
Err(e) => debug!("credentials_callback: failed to use SSH key from file {:?}: {e}", key.private_key),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sometimes libgit2 will ask for a username/password in plaintext.
|
||||
if allowed.contains(git2::CredentialType::USER_PASS_PLAINTEXT) {
|
||||
// Try provided plaintext credentials first.
|
||||
if let Some(credentials) = authenticator.get_plaintext_credentials(url) {
|
||||
debug!("credentials_callback: trying plain text credentials with username: {:?}", credentials.username);
|
||||
match credentials.to_credentials() {
|
||||
Ok(x) => return Ok(x),
|
||||
Err(e) => {
|
||||
debug!("credentials_callback: failed to wrap plain text credentials: {e}");
|
||||
return Err(e);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Try the git credential helper.
|
||||
if try_cred_helper {
|
||||
try_cred_helper = false;
|
||||
debug!("credentials_callback: trying credential_helper");
|
||||
match git2::Cred::credential_helper(git_config, url, username) {
|
||||
Ok(x) => return Ok(x),
|
||||
Err(e) => debug!("credentials_callback: failed to use credential helper: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
// Prompt the user on the terminal.
|
||||
if try_password_prompt > 0 {
|
||||
try_password_prompt -= 1;
|
||||
let credentials = PlaintextCredentials::prompt(
|
||||
prompter.as_prompter_mut(),
|
||||
username,
|
||||
url,
|
||||
git_config
|
||||
);
|
||||
if let Some(credentials) = credentials {
|
||||
return credentials.to_credentials();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(git2::Error::from_str("all authentication attempts failed"))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PrivateKeyFile {
|
||||
private_key: PathBuf,
|
||||
public_key: Option<PathBuf>,
|
||||
password: Option<String>,
|
||||
}
|
||||
|
||||
impl PrivateKeyFile {
|
||||
fn to_credentials(&self, username: &str, prompter: Option<&mut dyn Prompter>, git_config: &git2::Config) -> Result<git2::Cred, git2::Error> {
|
||||
if let Some(password) = &self.password {
|
||||
git2::Cred::ssh_key(username, self.public_key.as_deref(), &self.private_key, Some(password))
|
||||
} else if let Some(prompter) = prompter {
|
||||
let password = match ssh_key::analyze_ssh_key_file(&self.private_key) {
|
||||
Err(e) => {
|
||||
warn!("Failed to analyze SSH key: {}: {}", self.private_key.display(), e);
|
||||
None
|
||||
},
|
||||
Ok(key_info) => {
|
||||
if key_info.encrypted {
|
||||
prompter.prompt_ssh_key_passphrase(&self.private_key, git_config)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
};
|
||||
git2::Cred::ssh_key(username, self.public_key.as_deref(), &self.private_key, password.as_deref())
|
||||
} else {
|
||||
git2::Cred::ssh_key(username, self.public_key.as_deref(), &self.private_key, None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PlaintextCredentials {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
impl PlaintextCredentials {
|
||||
fn prompt(prompter: &mut dyn Prompter, username: Option<&str>, url: &str, git_config: &git2::Config) -> Option<Self> {
|
||||
if let Some(username) = username {
|
||||
let password = prompter.prompt_password(username, url, git_config)?;
|
||||
Some(Self {
|
||||
username: username.into(),
|
||||
password,
|
||||
})
|
||||
} else {
|
||||
let (username, password) = prompter.prompt_username_password(url, git_config)?;
|
||||
Some(Self {
|
||||
username,
|
||||
password,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn to_credentials(&self) -> Result<git2::Cred, git2::Error> {
|
||||
git2::Cred::userpass_plaintext(&self.username, &self.password)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_pub_key_path(priv_key_path: &Path) -> Option<PathBuf> {
|
||||
let name = priv_key_path.file_name()?;
|
||||
let name = name.to_str()?;
|
||||
let pub_key_path = priv_key_path.with_file_name(format!("{name}.pub"));
|
||||
if pub_key_path.is_file() {
|
||||
Some(pub_key_path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn domain_from_url(url: &str) -> Option<&str> {
|
||||
// We support:
|
||||
// Relative paths
|
||||
// Real URLs: scheme://[user[:pass]@]host/path
|
||||
// SSH URLs: [user@]host:path.
|
||||
|
||||
// If there is no colon: URL is a relative path and there is no domain (or need for credentials).
|
||||
let (head, tail) = url.split_once(':')?;
|
||||
|
||||
// Real URL
|
||||
if let Some(tail) = tail.strip_prefix("//") {
|
||||
let (_credentials, tail) = tail.split_once('@').unwrap_or(("", tail));
|
||||
let (host, _path) = tail.split_once('/').unwrap_or((tail, ""));
|
||||
Some(host)
|
||||
// SSH "URL"
|
||||
} else {
|
||||
let (_credentials, host) = head.split_once('@').unwrap_or(("", head));
|
||||
Some(host)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use assert2::assert;
|
||||
|
||||
#[test]
|
||||
fn test_domain_from_url() {
|
||||
assert!(let Some("host") = domain_from_url("user@host:path"));
|
||||
assert!(let Some("host") = domain_from_url("host:path"));
|
||||
assert!(let Some("host") = domain_from_url("host:path@with:stuff"));
|
||||
|
||||
assert!(let Some("host") = domain_from_url("ssh://user:pass@host/path"));
|
||||
assert!(let Some("host") = domain_from_url("ssh://user@host/path"));
|
||||
assert!(let Some("host") = domain_from_url("ssh://host/path"));
|
||||
|
||||
assert!(let None = domain_from_url("some/relative/path"));
|
||||
assert!(let None = domain_from_url("some/relative/path@with-at-sign"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_that_authenticator_is_send() {
|
||||
let authenticator = GitAuthenticator::new();
|
||||
let thread = std::thread::spawn(move || {
|
||||
drop(authenticator);
|
||||
});
|
||||
thread.join().unwrap();
|
||||
}
|
||||
}
|
65
src/prompter.rs
Normal file
65
src/prompter.rs
Normal file
|
@ -0,0 +1,65 @@
|
|||
use std::path::Path;
|
||||
|
||||
/// Trait for customizing user prompts.
|
||||
///
|
||||
/// You can provide an implementor of this trait to customize the way a user is prompted for credentials and passphrases.
|
||||
pub trait Prompter: Send {
|
||||
/// Promp the user for a username and password.
|
||||
///
|
||||
/// If the prompt fails or the user fails to provide the requested information, this function should return `None`.
|
||||
fn prompt_username_password(&mut self, url: &str, git_config: &git2::Config) -> Option<(String, String)>;
|
||||
|
||||
/// Promp the user for a password when the username is already known.
|
||||
///
|
||||
/// If the prompt fails or the user fails to provide the requested information, this function should return `None`.
|
||||
fn prompt_password(&mut self, username: &str, url: &str, git_config: &git2::Config) -> Option<String>;
|
||||
|
||||
/// Promp the user for the passphrase of an encrypted SSH key.
|
||||
///
|
||||
/// If the prompt fails or the user fails to provide the requested information, this function should return `None`.
|
||||
fn prompt_ssh_key_passphrase(&mut self, private_key_path: &Path, git_config: &git2::Config) -> Option<String>;
|
||||
}
|
||||
|
||||
/// Wrap a clonable [`Prompter`] in a `Box<dyn MakePrompter>`.
|
||||
pub(crate) fn wrap_prompter<P>(prompter: P) -> Box<dyn ClonePrompter>
|
||||
where
|
||||
P: Prompter + Clone + 'static,
|
||||
{
|
||||
Box::new(prompter)
|
||||
}
|
||||
|
||||
/// Trait to allow making clones of a `Box<dyn Prompter + Send>`.
|
||||
pub(crate) trait ClonePrompter: Prompter {
|
||||
/// Clone the `Box<dyn ClonePrompter>`.
|
||||
fn dyn_clone(&self) -> Box<dyn ClonePrompter>;
|
||||
|
||||
/// Get `self` as plain `Prompter`.
|
||||
fn as_prompter(&self) -> &dyn Prompter;
|
||||
|
||||
/// Get `self` as plain `Prompter`.
|
||||
fn as_prompter_mut(&mut self) -> &mut dyn Prompter;
|
||||
}
|
||||
|
||||
/// Implement `ClonePrompter` for clonable Prompters.
|
||||
impl<P> ClonePrompter for P
|
||||
where
|
||||
P: Prompter + Clone + 'static,
|
||||
{
|
||||
fn dyn_clone(&self) -> Box<dyn ClonePrompter> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
|
||||
fn as_prompter(&self) -> &dyn Prompter {
|
||||
self
|
||||
}
|
||||
|
||||
fn as_prompter_mut(&mut self) -> &mut dyn Prompter {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for Box<dyn ClonePrompter> {
|
||||
fn clone(&self) -> Self {
|
||||
self.dyn_clone()
|
||||
}
|
||||
}
|
156
src/ssh_key.rs
Normal file
156
src/ssh_key.rs
Normal file
|
@ -0,0 +1,156 @@
|
|||
use std::path::Path;
|
||||
|
||||
use crate::base64_decode;
|
||||
|
||||
/// An error that can occur when analyzing SSH keys.
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
/// Failed to open the key file.
|
||||
OpenFile(std::io::Error),
|
||||
|
||||
/// Failed to read from the key file.
|
||||
ReadFile(std::io::Error),
|
||||
|
||||
/// Missing PEM trailer in the file (there was a PEM header).
|
||||
MissingPemTrailer,
|
||||
|
||||
/// The key is not valid somehow.
|
||||
MalformedKey,
|
||||
|
||||
/// There was an invalid base64 blob in the key.
|
||||
Base64(base64_decode::Error),
|
||||
}
|
||||
|
||||
/// The format of a key file.
|
||||
pub enum KeyFormat {
|
||||
/// We don't know what format it is.
|
||||
Unknown,
|
||||
|
||||
/// It's an openssh-key-v1 file.
|
||||
///
|
||||
/// See https://coolaj86.com/articles/the-openssh-private-key-format/ for a description of the format.
|
||||
OpensshKeyV1,
|
||||
}
|
||||
|
||||
/// Information about a key file.
|
||||
pub struct KeyInfo {
|
||||
/// The format of the key file.
|
||||
pub format: KeyFormat,
|
||||
|
||||
/// Is the key encrypted?
|
||||
pub encrypted: bool,
|
||||
}
|
||||
|
||||
/// Analyze an SSH key file.
|
||||
pub fn analyze_ssh_key_file(priv_key_path: &Path) -> Result<KeyInfo, Error> {
|
||||
use std::io::Read;
|
||||
|
||||
let mut buffer = Vec::new();
|
||||
let mut file = std::fs::File::open(priv_key_path)
|
||||
.map_err(Error::OpenFile)?;
|
||||
file.read_to_end(&mut buffer)
|
||||
.map_err(Error::ReadFile)?;
|
||||
analyze_pem_openssh_key(&buffer)
|
||||
}
|
||||
|
||||
/// Analyze a PEM encoded openssh-key-v1 file.
|
||||
fn analyze_pem_openssh_key(data: &[u8]) -> Result<KeyInfo, Error> {
|
||||
let data = trim_bytes(data);
|
||||
let data = match data.strip_prefix(b"-----BEGIN OPENSSH PRIVATE KEY-----") {
|
||||
Some(x) => x,
|
||||
None => return Ok(KeyInfo { format: KeyFormat::Unknown, encrypted: false }),
|
||||
};
|
||||
let data = match data.strip_suffix(b"-----END OPENSSH PRIVATE KEY-----") {
|
||||
Some(x) => x,
|
||||
None => return Err(Error::MissingPemTrailer),
|
||||
};
|
||||
let data = base64_decode::base64_decode(data).map_err(Error::Base64)?;
|
||||
analyze_binary_openssh_key(&data)
|
||||
}
|
||||
|
||||
/// Analyze a binary openss-key-v1 blob.
|
||||
fn analyze_binary_openssh_key(data: &[u8]) -> Result<KeyInfo, Error> {
|
||||
let tail = data.strip_prefix(b"openssh-key-v1\0")
|
||||
.ok_or(Error::MalformedKey)?;
|
||||
if tail.len() <= 4 {
|
||||
return Err(Error::MalformedKey);
|
||||
}
|
||||
|
||||
let (cipher_len, tail) = tail.split_at(4);
|
||||
let cipher_len = u32::from_be_bytes(cipher_len.try_into().unwrap()) as usize;
|
||||
if tail.len() < cipher_len {
|
||||
return Err(Error::MalformedKey);
|
||||
}
|
||||
let cipher = &tail[..cipher_len];
|
||||
let encrypted = cipher != b"none";
|
||||
Ok(KeyInfo { format: KeyFormat::OpensshKeyV1, encrypted })
|
||||
}
|
||||
|
||||
/// Trim whitespace from the start and end of a byte slice.
|
||||
fn trim_bytes(data: &[u8]) -> &[u8] {
|
||||
let data = match data.iter().position(|b| !b.is_ascii_whitespace()) {
|
||||
Some(x) => &data[x..],
|
||||
None => return b"",
|
||||
};
|
||||
let data = match data.iter().rposition(|b| !b.is_ascii_whitespace()) {
|
||||
Some(x) => &data[..=x],
|
||||
None => return b"",
|
||||
};
|
||||
data
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::OpenFile(e) => write!(f, "Failed to open file: {e}"),
|
||||
Self::ReadFile(e) => write!(f, "Failed to read from file: {e}"),
|
||||
Self::MissingPemTrailer => write!(f, "Missing PEM trailer in key file"),
|
||||
Self::MalformedKey => write!(f, "Invalid or malformed key file"),
|
||||
Self::Base64(e) => write!(f, "Invalid base64 in key file: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use assert2::assert;
|
||||
|
||||
#[test]
|
||||
fn test_is_encrypted_pem_openssh_key() {
|
||||
// Encrypted OpenSSH key.
|
||||
assert!(let Ok(KeyInfo { format: KeyFormat::OpensshKeyV1, encrypted: true }) = analyze_pem_openssh_key(concat!(
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----\n",
|
||||
"b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABBddrJWnj\n",
|
||||
"6eysG+DqTberHEAAAAEAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIARNG0xAyCq6/OFQ\n",
|
||||
"8eQFG1zKYlhtLLz2GC3Sou+C9PTmAAAAoGPGz6ZQhBk8FL4MRDaGsaZuVkPAn/+curIR7r\n",
|
||||
"rDoXPAf0/7S2dVWY0gUjolhwlqGFnps4NgukXtKNs4qlAJiVAY/kKPr0fN+ZScuNuKP/Im\n",
|
||||
"JbFoNPRaakzgbBwj9/UTpwNgUJa+3fu25l1RMLlrx7OjkQKAHBb6VMsGqH8k9rAEsCCBUK\n",
|
||||
"XVJQOMAfa214eo9wgHD06ZnIlk3jS++3hzyUs=\n",
|
||||
"-----END OPENSSH PRIVATE KEY-----\n",
|
||||
).as_bytes()));
|
||||
|
||||
// Encrypted OpenSSH key with extra random whitespace.
|
||||
assert!(let Ok(KeyInfo { format: KeyFormat::OpensshKeyV1, encrypted: true }) = analyze_pem_openssh_key(concat!(
|
||||
" \n\t\r-----BEGIN OPENSSH PRIVATE KEY-----\n",
|
||||
"b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABBddrJWnj\n",
|
||||
"6eysG+DqTberHEAAAAEAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIARNG0xAyCq6/OFQ\n \r",
|
||||
"8eQFG1zKYlhtLLz2GC3Sou+ C9PTmAAAAoGPGz6ZQhBk8FL4MRDaGsaZuVkPAn/+curIR7r\n",
|
||||
"rDoXPAf0/7S2dVWY0gUjolhwlqGFnps4NgukXtKNs4qlAJiVAY/kKPr0fN+ZScuNuKP/Im\n",
|
||||
"JbFoNPRaakzgbBwj9/UTpwNgUJa+3fu25l1RMLlrx7OjkQKAHBb6VMsGqH8k9rAEsCCBUK\n",
|
||||
"XVJQOMAfa214eo9wgHD06ZnIlk3jS++3hzyUs=\n",
|
||||
"-----END OPENSSH PRIVATE KEY-----",
|
||||
).as_bytes()));
|
||||
|
||||
// Unencrypted OpenSSH key.
|
||||
assert!(let Ok(KeyInfo { format: KeyFormat::OpensshKeyV1, encrypted: false }) = analyze_pem_openssh_key(concat!(
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----\n",
|
||||
"b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\n",
|
||||
"QyNTUxOQAAACDTKM0+RYzELoLewv5n5UoEPhmCpwkrtXM4GpWUVF+w3AAAAJhSNRa9UjUW\n",
|
||||
"vQAAAAtzc2gtZWQyNTUxOQAAACDTKM0+RYzELoLewv5n5UoEPhmCpwkrtXM4GpWUVF+w3A\n",
|
||||
"AAAECZObXz1xTSvl4vpLsMVTuhjroyDteKlW+Uun0yIMl7edMozT5FjMQugt7C/mflSgQ+\n",
|
||||
"GYKnCSu1czgalZRUX7DcAAAAEW1hYXJ0ZW5AbWFnbmV0cm9uAQIDBA==\n",
|
||||
"-----END OPENSSH PRIVATE KEY-----\n",
|
||||
).as_bytes()));
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue