296 lines
9.2 KiB
Rust
296 lines
9.2 KiB
Rust
//! 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<char>,
|
|
alphanumeric_set: Vec<char>,
|
|
}
|
|
|
|
impl GeneratorConfig {
|
|
pub fn new(default_length: usize, character_set: &str) -> Result<Self, GenerateError> {
|
|
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<NonZeroUsize>,
|
|
no_symbols: bool,
|
|
) -> Result<SecretBytes, GenerateError> {
|
|
self.generate_secret_with_rng(length, no_symbols, &mut OsRng)
|
|
}
|
|
|
|
fn generate_secret_with_rng<R: RngCore + CryptoRng>(
|
|
&self,
|
|
length: Option<NonZeroUsize>,
|
|
no_symbols: bool,
|
|
rng: &mut R,
|
|
) -> Result<SecretBytes, GenerateError> {
|
|
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<GenerateOutcome, GenerateError> {
|
|
self.generate_with_rng(request, overwrite, signing, provider, committer, &mut OsRng)
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn generate_with_rng<R: RngCore + CryptoRng>(
|
|
&self,
|
|
request: &GenerateRequest,
|
|
overwrite: OverwriteDecision,
|
|
signing: Option<&SigningPolicy>,
|
|
provider: &mut impl SecretProvider,
|
|
committer: &mut impl EntryCommitter,
|
|
rng: &mut R,
|
|
) -> Result<GenerateOutcome, GenerateError> {
|
|
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<R: RngCore + CryptoRng>(
|
|
rng: &mut R,
|
|
length: NonZeroUsize,
|
|
characters: &[char],
|
|
) -> Result<SecretBytes, GenerateError> {
|
|
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<NonZeroUsize, GenerateError> {
|
|
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<Vec<char>, GenerateError> {
|
|
let characters = character_set.chars().collect::<Vec<_>>();
|
|
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<RepositoryError> for GenerateError {
|
|
fn from(error: RepositoryError) -> Self {
|
|
Self::Repository(error)
|
|
}
|
|
}
|
|
|
|
impl From<WriteError> for GenerateError {
|
|
fn from(error: WriteError) -> Self {
|
|
Self::Write(error)
|
|
}
|
|
}
|
|
|
|
impl From<CryptoError> for GenerateError {
|
|
fn from(error: CryptoError) -> Self {
|
|
Self::Crypto(error)
|
|
}
|
|
}
|