Implement secure password generation (#8)
This commit is contained in:
275
crates/storage/src/generate.rs
Normal file
275
crates/storage/src/generate.rs
Normal file
@@ -0,0 +1,275 @@
|
||||
//! 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
|
||||
}
|
||||
}
|
||||
|
||||
#[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 length = request.length.unwrap_or(self.config.default_length);
|
||||
let length = validate_length(length.get())?;
|
||||
let characters = if request.no_symbols {
|
||||
&self.config.alphanumeric_set
|
||||
} else {
|
||||
&self.config.character_set
|
||||
};
|
||||
let password = generate_password(rng, length, characters)?;
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
pub mod command;
|
||||
pub mod config;
|
||||
pub mod crypto;
|
||||
pub mod generate;
|
||||
pub mod read;
|
||||
pub mod recipient;
|
||||
pub mod repository;
|
||||
|
||||
@@ -248,6 +248,48 @@ impl<'a> VaultWriter<'a> {
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn store_generated(
|
||||
&self,
|
||||
path: &EntryPath,
|
||||
contents: SecretBytes,
|
||||
force: bool,
|
||||
overwrite: OverwriteDecision,
|
||||
signing: Option<&SigningPolicy>,
|
||||
committer: &mut impl EntryCommitter,
|
||||
) -> Result<WriteOutcome, WriteError> {
|
||||
let original = match self.repository.read_entry(path) {
|
||||
Ok(original) => Some(original),
|
||||
Err(RepositoryError::NotFound { .. }) => None,
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
if original.is_some() && !force && overwrite == OverwriteDecision::Decline {
|
||||
return Err(WriteError::Cancelled);
|
||||
}
|
||||
let recipients = RecipientPolicyManager::new(self.repository, self.keys)
|
||||
.resolve_for_entry(path, signing)?;
|
||||
let ciphertext = self.keys.encrypt(contents, recipients.recipients())?;
|
||||
self.repository.write_entry(path, &ciphertext)?;
|
||||
let change = EntryCommit {
|
||||
path: path.clone(),
|
||||
action: EntryAction::Insert,
|
||||
message: format!("Add generated password for {path}."),
|
||||
};
|
||||
if let Err(error) = committer.commit(&change) {
|
||||
if let Err(rollback) = self.restore(path, original.as_ref()) {
|
||||
return Err(WriteError::RollbackFailed {
|
||||
operation: error,
|
||||
rollback,
|
||||
});
|
||||
}
|
||||
return Err(WriteError::Commit(error));
|
||||
}
|
||||
Ok(WriteOutcome {
|
||||
path: path.clone(),
|
||||
action: EntryAction::Insert,
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn finish_edit(
|
||||
&self,
|
||||
|
||||
Reference in New Issue
Block a user