Implement secure password generation (#8)

This commit is contained in:
Hermes Agent
2026-08-09 22:41:33 +00:00
parent 0e4cbef944
commit 834df46818
6 changed files with 726 additions and 0 deletions

View File

@@ -38,6 +38,9 @@ Typed list/show/find/decrypted-grep models and secret presentation selection are
documented in [`docs/read-domains.md`](docs/read-domains.md).
Insert modes, concurrency-safe edit sessions, and the secure CLI editor-file
boundary are documented in [`docs/write-domains.md`](docs/write-domains.md).
Unbiased password generation, character-set validation, in-place replacement,
and presentation actions are documented in
[`docs/password-generation.md`](docs/password-generation.md).
## Project layout

View 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)
}
}

View File

@@ -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;

View File

@@ -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,

View File

@@ -0,0 +1,380 @@
#![forbid(unsafe_code)]
mod support;
use std::{collections::BTreeMap, num::NonZeroUsize};
use ironstorage::{
command::{GenerateRequest, GeneratedPresentation},
crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError},
generate::{
GenerateError, GeneratedChannel, GeneratorConfig, MAX_PASSWORD_LENGTH, PasswordGenerator,
},
repository::{EntryPath, Repository, SecretBytes},
write::{EntryCommit, EntryCommitError, EntryCommitter, OverwriteDecision, WriteError},
};
use rand_chacha::{
ChaCha20Rng,
rand_core::{CryptoRng, Error as RandError, RngCore, SeedableRng},
};
use support::compatibility::{FixtureSet, TestResult};
struct FixtureSecrets(BTreeMap<String, Vec<u8>>);
impl FixtureSecrets {
fn all(fixture: &FixtureSet) -> Self {
Self(
fixture
.generated
.keys
.iter()
.map(|key| {
(
key.primary_fingerprint.clone(),
key.passphrase.as_bytes().to_vec(),
)
})
.collect(),
)
}
}
impl SecretProvider for FixtureSecrets {
fn secret_for(&mut self, key: &KeyInfo) -> Result<SecretBytes, SecretProviderError> {
self.0
.get(key.fingerprint().as_str())
.cloned()
.map(SecretBytes::new)
.ok_or(SecretProviderError::Unavailable)
}
}
#[derive(Default)]
struct Committer {
changes: Vec<EntryCommit>,
fail: bool,
}
struct FailingRng;
impl RngCore for FailingRng {
fn next_u32(&mut self) -> u32 {
0
}
fn next_u64(&mut self) -> u64 {
0
}
fn fill_bytes(&mut self, _destination: &mut [u8]) {
panic!("fallible generator must use try_fill_bytes")
}
fn try_fill_bytes(&mut self, _destination: &mut [u8]) -> Result<(), RandError> {
Err(RandError::new("simulated unavailable randomness"))
}
}
impl CryptoRng for FailingRng {}
impl EntryCommitter for Committer {
fn commit(&mut self, change: &EntryCommit) -> Result<(), EntryCommitError> {
self.changes.push(change.clone());
if self.fail {
Err(EntryCommitError::new("simulated"))
} else {
Ok(())
}
}
}
#[test]
fn deterministic_generation_uses_only_requested_characters_and_length() -> TestResult {
let fixture = FixtureSet::load()?;
let store = fixture.materialize_store("basic")?;
let repository = Repository::open(store.path())?;
let keys = KeyStore::load(fixture.path("keys"))?;
let service = PasswordGenerator::new(&repository, &keys, GeneratorConfig::new(12, "abc123")?);
let mut rng = ChaCha20Rng::from_seed([0x81; 32]);
let mut provider = FixtureSecrets::all(&fixture);
let mut committer = Committer::default();
let outcome = service.generate_with_rng(
&request(
"generated/custom",
Some(64),
false,
false,
false,
GeneratedPresentation::Terminal,
),
OverwriteDecision::Allow,
None,
&mut provider,
&mut committer,
&mut rng,
)?;
assert_eq!(outcome.password().expose().len(), 64);
assert!(
outcome
.password()
.expose()
.iter()
.all(|byte| b"abc123".contains(byte))
);
assert_eq!(outcome.channel(), GeneratedChannel::Terminal);
assert!(!format!("{outcome:?}").contains(std::str::from_utf8(outcome.password().expose())?));
assert_eq!(
committer.changes[0].message(),
"Add generated password for generated/custom."
);
Ok(())
}
#[test]
fn default_no_symbols_and_presentation_actions_are_typed() -> TestResult {
let fixture = FixtureSet::load()?;
let store = fixture.materialize_store("basic")?;
let repository = Repository::open(store.path())?;
let keys = KeyStore::load(fixture.path("keys"))?;
let service = PasswordGenerator::new(&repository, &keys, GeneratorConfig::pass_defaults());
let mut provider = FixtureSecrets::all(&fixture);
let mut committer = Committer::default();
let mut rng = ChaCha20Rng::from_seed([0x82; 32]);
for (path, presentation, channel) in [
(
"generated/clip",
GeneratedPresentation::Clipboard,
GeneratedChannel::Clipboard,
),
(
"generated/qr",
GeneratedPresentation::QrCode,
GeneratedChannel::QrCode,
),
] {
let outcome = service.generate_with_rng(
&request(path, None, true, false, false, presentation),
OverwriteDecision::Allow,
None,
&mut provider,
&mut committer,
&mut rng,
)?;
assert_eq!(outcome.password().expose().len(), 25);
assert!(
outcome
.password()
.expose()
.iter()
.all(u8::is_ascii_alphanumeric)
);
assert_eq!(outcome.channel(), channel);
}
Ok(())
}
#[test]
fn in_place_replaces_only_first_line_and_preserves_suffix_bytes() -> TestResult {
let fixture = FixtureSet::load()?;
let store = fixture.materialize_store("basic")?;
let repository = Repository::open(store.path())?;
let keys = KeyStore::load(fixture.path("keys"))?;
let service = PasswordGenerator::new(&repository, &keys, GeneratorConfig::new(8, "XYZ")?);
let mut provider = FixtureSecrets::all(&fixture);
let mut committer = Committer::default();
let mut rng = ChaCha20Rng::from_seed([0x83; 32]);
let original = fixture.read("expected/basic/email/personal.txt")?;
let suffix = &original[original
.iter()
.position(|byte| *byte == b'\n')
.expect("newline")..];
let outcome = service.generate_with_rng(
&request(
"email/personal",
Some(8),
false,
false,
true,
GeneratedPresentation::Terminal,
),
OverwriteDecision::Decline,
None,
&mut provider,
&mut committer,
&mut rng,
)?;
let plaintext = keys.decrypt(
&repository.read_entry(&EntryPath::parse("email/personal")?)?,
&mut provider,
)?;
assert_eq!(&plaintext.expose()[8..], suffix);
assert_eq!(&plaintext.expose()[..8], outcome.password().expose());
Ok(())
}
#[test]
fn validates_lengths_character_sets_and_incompatible_flags() -> TestResult {
assert!(matches!(
GeneratorConfig::new(0, "abc"),
Err(GenerateError::InvalidLength)
));
assert!(matches!(
GeneratorConfig::new(MAX_PASSWORD_LENGTH + 1, "abc"),
Err(GenerateError::InvalidLength)
));
assert!(matches!(
GeneratorConfig::new(10, ""),
Err(GenerateError::EmptyCharacterSet)
));
assert!(matches!(
GeneratorConfig::new(10, "aab"),
Err(GenerateError::InvalidCharacterSet)
));
assert!(matches!(
GeneratorConfig::new(10, "a\nb"),
Err(GenerateError::InvalidCharacterSet)
));
let fixture = FixtureSet::load()?;
let store = fixture.materialize_store("basic")?;
let repository = Repository::open(store.path())?;
let keys = KeyStore::load(fixture.path("keys"))?;
let service = PasswordGenerator::new(&repository, &keys, GeneratorConfig::pass_defaults());
let mut provider = FixtureSecrets::all(&fixture);
let mut rng = ChaCha20Rng::from_seed([0x84; 32]);
assert!(matches!(
service.generate_with_rng(
&request(
"entry",
Some(8),
false,
true,
true,
GeneratedPresentation::Terminal
),
OverwriteDecision::Allow,
None,
&mut provider,
&mut Committer::default(),
&mut rng,
),
Err(GenerateError::IncompatibleFlags)
));
assert!(matches!(
service.generate_with_rng(
&request(
"randomness-failure",
Some(8),
false,
false,
false,
GeneratedPresentation::Terminal,
),
OverwriteDecision::Allow,
None,
&mut provider,
&mut Committer::default(),
&mut FailingRng,
),
Err(GenerateError::RandomnessUnavailable)
));
assert!(!store.path().join("randomness-failure.gpg").exists());
Ok(())
}
#[test]
fn decline_missing_in_place_and_commit_failure_leave_entries_unchanged() -> TestResult {
let fixture = FixtureSet::load()?;
let store = fixture.materialize_store("basic")?;
let repository = Repository::open(store.path())?;
let keys = KeyStore::load(fixture.path("keys"))?;
let service = PasswordGenerator::new(&repository, &keys, GeneratorConfig::pass_defaults());
let path = EntryPath::parse("email/personal")?;
let original = repository.read_entry(&path)?;
let mut provider = FixtureSecrets::all(&fixture);
let mut rng = ChaCha20Rng::from_seed([0x85; 32]);
assert!(matches!(
service.generate_with_rng(
&request(
"email/personal",
Some(8),
false,
false,
false,
GeneratedPresentation::Terminal
),
OverwriteDecision::Decline,
None,
&mut provider,
&mut Committer::default(),
&mut rng,
),
Err(GenerateError::Write(WriteError::Cancelled))
));
assert_eq!(repository.read_entry(&path)?, original);
assert!(matches!(
service.generate_with_rng(
&request(
"missing",
Some(8),
false,
false,
true,
GeneratedPresentation::Terminal
),
OverwriteDecision::Allow,
None,
&mut provider,
&mut Committer::default(),
&mut rng,
),
Err(GenerateError::Repository(_))
));
let mut failing = Committer {
fail: true,
..Committer::default()
};
assert!(matches!(
service.generate_with_rng(
&request(
"email/personal",
Some(8),
false,
true,
false,
GeneratedPresentation::Terminal
),
OverwriteDecision::Allow,
None,
&mut provider,
&mut failing,
&mut rng,
),
Err(GenerateError::Write(WriteError::Commit(_)))
));
assert_eq!(repository.read_entry(&path)?, original);
Ok(())
}
fn request(
entry: &str,
length: Option<usize>,
no_symbols: bool,
force: bool,
in_place: bool,
presentation: GeneratedPresentation,
) -> GenerateRequest {
GenerateRequest {
entry: entry.to_owned(),
length: length.map(|length| NonZeroUsize::new(length).expect("test length is nonzero")),
no_symbols,
force,
in_place,
presentation,
}
}

View File

@@ -0,0 +1,25 @@
# Secure password generation
`PasswordGenerator` owns `pass generate` semantics in `crates/storage`.
Production calls use `OsRng`; tests inject a deterministic cryptographic RNG.
Random bytes are converted to character indexes with fallible rejection
sampling, so arbitrary set sizes have no modulo bias and operating-system RNG
failure is reported before repository mutation.
The default is 25 characters from printable ASCII punctuation and
alphanumerics. `--no-symbols` selects ASCII letters and digits. A
`GeneratorConfig` may supply another Unicode character set and default length.
Lengths must be 1 through 4096. Sets must be nonempty and contain no duplicate
or control characters; rejecting duplicates prevents accidental weighting.
Normal generation uses the same overwrite decision and `--force` rules as
insert. `--in-place` requires an existing entry, decrypts it, replaces only the
bytes before its first newline, and preserves that newline and every following
byte exactly. The generated password is returned separately as redacted,
zeroizing data for typed terminal, clipboard, or QR presentation.
The completed entry is encrypted for the nearest recipient policy and written
atomically. Embedded Git receives `Add generated password for ...` only after a
successful write. Decline, missing in-place targets, validation, randomness,
encryption, and commit failures leave the previous repository state intact;
commit failure uses the write-domain rollback path.