//! Unbiased cryptographic password generation and storage orchestration. use std::{collections::BTreeSet, error::Error, fmt, num::NonZeroUsize}; use rand::{CryptoRng, RngCore, rngs::OsRng}; use crate::{ command::{GenerateRequest, GeneratedPresentation}, crypto::{CryptoError, KeyStore, SecretProvider}, recipient::SigningPolicy, repository::{EntryPath, Repository, RepositoryError, SecretBytes}, write::{EntryCommitter, OverwriteDecision, VaultWriter, WriteError, WriteOutcome}, }; pub const DEFAULT_PASSWORD_LENGTH: usize = 25; pub const MAX_PASSWORD_LENGTH: usize = 4096; pub const DEFAULT_CHARACTER_SET: &str = "!\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"; pub const ALPHANUMERIC_CHARACTER_SET: &str = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; #[derive(Clone, Debug, Eq, PartialEq)] pub struct GeneratorConfig { default_length: NonZeroUsize, character_set: Vec, alphanumeric_set: Vec, } impl GeneratorConfig { pub fn new(default_length: usize, character_set: &str) -> Result { Ok(Self { default_length: validate_length(default_length)?, character_set: validate_character_set(character_set)?, alphanumeric_set: validate_character_set(ALPHANUMERIC_CHARACTER_SET)?, }) } pub fn pass_defaults() -> Self { Self::new(DEFAULT_PASSWORD_LENGTH, DEFAULT_CHARACTER_SET) .expect("built-in generator defaults are valid") } pub fn default_length(&self) -> NonZeroUsize { self.default_length } pub fn character_set(&self) -> &[char] { &self.character_set } /// Generate a zeroizing password for an in-process structured editor /// without mutating a repository. pub fn generate_secret( &self, length: Option, no_symbols: bool, ) -> Result { self.generate_secret_with_rng(length, no_symbols, &mut OsRng) } fn generate_secret_with_rng( &self, length: Option, no_symbols: bool, rng: &mut R, ) -> Result { let length = validate_length(length.unwrap_or(self.default_length).get())?; let characters = if no_symbols { &self.alphanumeric_set } else { &self.character_set }; generate_password(rng, length, characters) } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum GeneratedChannel { Terminal, Clipboard, QrCode, } pub struct GenerateOutcome { password: SecretBytes, channel: GeneratedChannel, write: WriteOutcome, } impl GenerateOutcome { pub fn password(&self) -> &SecretBytes { &self.password } pub fn channel(&self) -> GeneratedChannel { self.channel } pub fn write(&self) -> &WriteOutcome { &self.write } } impl fmt::Debug for GenerateOutcome { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("GenerateOutcome") .field("password", &self.password) .field("channel", &self.channel) .field("write", &self.write) .finish() } } pub struct PasswordGenerator<'a> { repository: &'a Repository, keys: &'a KeyStore, config: GeneratorConfig, } impl<'a> PasswordGenerator<'a> { pub fn new(repository: &'a Repository, keys: &'a KeyStore, config: GeneratorConfig) -> Self { Self { repository, keys, config, } } #[allow(clippy::too_many_arguments)] pub fn generate( &self, request: &GenerateRequest, overwrite: OverwriteDecision, signing: Option<&SigningPolicy>, provider: &mut impl SecretProvider, committer: &mut impl EntryCommitter, ) -> Result { self.generate_with_rng(request, overwrite, signing, provider, committer, &mut OsRng) } #[allow(clippy::too_many_arguments)] pub fn generate_with_rng( &self, request: &GenerateRequest, overwrite: OverwriteDecision, signing: Option<&SigningPolicy>, provider: &mut impl SecretProvider, committer: &mut impl EntryCommitter, rng: &mut R, ) -> Result { if request.force && request.in_place { return Err(GenerateError::IncompatibleFlags); } let password = self.config .generate_secret_with_rng(request.length, request.no_symbols, rng)?; let path = EntryPath::parse(&request.entry)?; let contents = if request.in_place { let ciphertext = self.repository.read_entry(&path)?; let existing = self.keys.decrypt(&ciphertext, provider)?; replace_first_line(&password, &existing) } else { SecretBytes::new(password.expose().to_vec()) }; let write = VaultWriter::new(self.repository, self.keys).store_generated( &path, contents, request.force || request.in_place, overwrite, signing, committer, )?; Ok(GenerateOutcome { password, channel: match request.presentation { GeneratedPresentation::Terminal => GeneratedChannel::Terminal, GeneratedPresentation::Clipboard => GeneratedChannel::Clipboard, GeneratedPresentation::QrCode => GeneratedChannel::QrCode, }, write, }) } } fn generate_password( rng: &mut R, length: NonZeroUsize, characters: &[char], ) -> Result { if characters.is_empty() { return Err(GenerateError::EmptyCharacterSet); } let mut password = String::with_capacity(length.get()); for _ in 0..length.get() { let range = characters.len() as u64; let unbiased_limit = u64::MAX - (u64::MAX % range); let value = loop { let mut bytes = [0_u8; 8]; rng.try_fill_bytes(&mut bytes) .map_err(|_| GenerateError::RandomnessUnavailable)?; let value = u64::from_le_bytes(bytes); if value < unbiased_limit { break value; } }; password.push(characters[(value % range) as usize]); } Ok(SecretBytes::new(password.into_bytes())) } fn replace_first_line(password: &SecretBytes, existing: &SecretBytes) -> SecretBytes { let suffix = existing .expose() .iter() .position(|byte| *byte == b'\n') .map_or(&[][..], |index| &existing.expose()[index..]); let mut replacement = Vec::with_capacity(password.expose().len() + suffix.len()); replacement.extend_from_slice(password.expose()); replacement.extend_from_slice(suffix); SecretBytes::new(replacement) } fn validate_length(length: usize) -> Result { let length = NonZeroUsize::new(length).ok_or(GenerateError::InvalidLength)?; if length.get() > MAX_PASSWORD_LENGTH { return Err(GenerateError::InvalidLength); } Ok(length) } fn validate_character_set(character_set: &str) -> Result, GenerateError> { let characters = character_set.chars().collect::>(); if characters.is_empty() { return Err(GenerateError::EmptyCharacterSet); } let mut unique = BTreeSet::new(); if characters .iter() .any(|character| character.is_control() || !unique.insert(*character)) { return Err(GenerateError::InvalidCharacterSet); } Ok(characters) } #[derive(Debug)] pub enum GenerateError { Repository(RepositoryError), Crypto(CryptoError), Write(WriteError), InvalidLength, EmptyCharacterSet, InvalidCharacterSet, IncompatibleFlags, RandomnessUnavailable, } impl fmt::Display for GenerateError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Repository(error) => error.fmt(formatter), Self::Crypto(error) => error.fmt(formatter), Self::Write(error) => error.fmt(formatter), Self::InvalidLength => formatter.write_str("password length is invalid"), Self::EmptyCharacterSet => formatter.write_str("password character set is empty"), Self::InvalidCharacterSet => formatter.write_str("password character set is invalid"), Self::IncompatibleFlags => { formatter.write_str("--force and --in-place cannot be combined") } Self::RandomnessUnavailable => { formatter.write_str("operating-system randomness is unavailable") } } } } impl Error for GenerateError {} impl From for GenerateError { fn from(error: RepositoryError) -> Self { Self::Repository(error) } } impl From for GenerateError { fn from(error: WriteError) -> Self { Self::Write(error) } } impl From for GenerateError { fn from(error: CryptoError) -> Self { Self::Crypto(error) } }