#![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>); 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 { self.0 .get(key.fingerprint().as_str()) .cloned() .map(SecretBytes::new) .ok_or(SecretProviderError::Unavailable) } } #[derive(Default)] struct Committer { changes: Vec, 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 in_process_generation_returns_a_zeroizing_unstored_secret() -> TestResult { let config = GeneratorConfig::new(12, "abc123")?; let generated = config.generate_secret(NonZeroUsize::new(48), false)?; assert_eq!(generated.expose().len(), 48); assert!( generated .expose() .iter() .all(|byte| b"abc123".contains(byte)) ); assert!(!format!("{generated:?}").contains(std::str::from_utf8(generated.expose())?)); 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, 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, } }