396 lines
12 KiB
Rust
396 lines
12 KiB
Rust
#![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 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<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,
|
|
}
|
|
}
|