#![forbid(unsafe_code)] mod support; use std::{collections::BTreeMap, fs, io::Write as _}; use data_encoding::BASE32_NOPAD; use ironstorage::{ command::{OtpAppendRequest, OtpInputSource, OtpInsertRequest}, crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError}, desktop::{DesktopErrorKind, DesktopStorage}, git::{GitIdentity, GitRepository}, otp::{OtpAlgorithm, OtpCodeValidity, OtpError, OtpInput, OtpKind, OtpService, OtpUri}, recipient::RecipientPolicyManager, repository::{EntryPath, Repository, SecretBytes}, write::{EntryCommit, EntryCommitError, EntryCommitter, OverwriteDecision}, }; 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, } impl EntryCommitter for Committer { fn commit(&mut self, change: &EntryCommit) -> Result<(), EntryCommitError> { self.changes.push(change.clone()); if self.fail { Err(EntryCommitError::new("simulated Git failure")) } else { Ok(()) } } } #[test] fn key_uri_parsing_defaults_derivation_and_validation_are_typed() -> TestResult { let text = "otpauth://totp/Example:alice%40example.test?secret=jbswy3dpehpk3pxp&issuer=Example"; let uri = OtpUri::parse_str(text)?; assert_eq!(uri.encoded().expose(), text.as_bytes()); assert_eq!(uri.kind(), OtpKind::Totp); assert_eq!(uri.issuer(), Some("Example")); assert_eq!(uri.account(), "alice@example.test"); assert_eq!(uri.algorithm(), OtpAlgorithm::Sha1); assert_eq!(uri.digits(), 6); assert_eq!(uri.period(), Some(30)); assert_eq!(uri.counter(), None); assert_eq!( uri.derived_entry()?.to_string(), "Example/alice@example.test" ); assert!(!format!("{uri:?}").contains("jbswy3dpehpk3pxp")); let derived = OtpUri::from_input( &OtpInputSource::Secret { issuer: Some("ACME Co".to_owned()), account: Some("alice@example.test".to_owned()), }, OtpInput::line(b"JBSWY3DPEHPK3PXP".to_vec())?, )?; assert_eq!( derived.encoded().expose(), b"otpauth://totp/ACME+Co:alice%40example.test?secret=JBSWY3DPEHPK3PXP&issuer=ACME+Co" ); assert_eq!( derived.derived_entry()?.to_string(), "ACME Co/alice@example.test" ); let custom = OtpUri::parse_str( "otpauth://totp/custom?secret=JBSWY3DPEHPK3PXP&algorithm=SHA256&digits=8&period=60", )?; assert_eq!(custom.period(), Some(60)); assert_eq!(custom.digits(), 8); assert_eq!( custom.code_at(119)?.expose(), custom.code_for_counter(1)?.expose() ); for (invalid, expected) in [ ("https://example.test/not-otp", OtpError::InvalidScheme), ( "otpauth://totp/account?issuer=Example", OtpError::MissingSecret, ), ( "otpauth://hotp/account?secret=JBSWY3DPEHPK3PXP", OtpError::MissingCounter, ), ( "otpauth://totp/account?secret=not-base32!", OtpError::InvalidSecret, ), ( "otpauth://totp/account?secret=MY========", OtpError::InvalidSecret, ), ( "otpauth://totp/A:account?secret=JBSWY3DPEHPK3PXP&issuer=B", OtpError::IssuerMismatch, ), ] { assert_eq!( std::mem::discriminant(&OtpUri::parse_str(invalid).expect_err("invalid URI")), std::mem::discriminant(&expected) ); } assert!(matches!( OtpUri::parse_str("otpauth://totp/account?secret=JBSWY3DPEHPK3PXP&secret=JBSWY3DPEHPK3PXP"), Err(OtpError::DuplicateParameter(_)) )); assert!(matches!( OtpInput::hidden(b"one".to_vec(), b"two".to_vec()), Err(OtpError::ConfirmationMismatch) )); assert!(matches!( OtpInput::line(b"two\nlines".to_vec()), Err(OtpError::InvalidInput) )); Ok(()) } #[test] fn rfc4226_hotp_vectors_pass() -> TestResult { let secret = BASE32_NOPAD.encode(b"12345678901234567890"); let uri = OtpUri::parse_str(&format!("otpauth://hotp/RFC4226?secret={secret}&counter=0"))?; for (counter, expected) in [ "755224", "287082", "359152", "969429", "338314", "254676", "287922", "162583", "399871", "520489", ] .into_iter() .enumerate() { assert_eq!( uri.code_for_counter(counter as u64)?.expose(), expected.as_bytes() ); } Ok(()) } #[test] fn rfc6238_totp_vectors_cover_all_algorithms() -> TestResult { let vectors = [ (59, "94287082", "46119246", "90693936"), (1_111_111_109, "07081804", "68084774", "25091201"), (1_111_111_111, "14050471", "67062674", "99943326"), (1_234_567_890, "89005924", "91819424", "93441116"), (2_000_000_000, "69279037", "90698825", "38618901"), (20_000_000_000, "65353130", "77737706", "47863826"), ]; let tokens = [ token(b"12345678901234567890", "SHA1")?, token(b"12345678901234567890123456789012", "SHA256")?, token( b"1234567890123456789012345678901234567890123456789012345678901234", "SHA512", )?, ]; for (timestamp, sha1, sha256, sha512) in vectors { for (token, expected) in tokens.iter().zip([sha1, sha256, sha512]) { assert_eq!(token.code_at(timestamp)?.expose(), expected.as_bytes()); } } Ok(()) } #[test] fn pass_otp_fixtures_round_trip_code_uri_insert_and_append() -> 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 = OtpService::new(&repository, &keys); let mut provider = FixtureSecrets::all(&fixture); let mut committer = Committer::default(); let fixture_uri = fixture .read("expected/basic/otp/totp.txt")? .split(|byte| *byte == b'\n') .find(|line| line.starts_with(b"otpauth://")) .expect("fixture URI") .to_vec(); OtpService::validate(std::str::from_utf8(&fixture_uri)?)?; assert_eq!( service.uri("otp/totp", &mut provider)?.encoded().expose(), fixture_uri ); let code = service.code("otp/totp", 59, None, &mut provider, &mut committer)?; assert_eq!(code.code().expose().len(), 6); assert!(code.code().expose().iter().all(u8::is_ascii_digit)); assert!(committer.changes.is_empty()); let replacement_uri = "otpauth://totp/Replaced:alice?secret=JBSWY3DPEHPK3PXP&issuer=Replaced"; let replace_request = OtpAppendRequest { entry: "otp/totp".to_owned(), force: true, echo: true, source: OtpInputSource::Uri, }; let replace_session = service.begin_append(&replace_request, &mut provider)?; assert!(!replace_session.requires_replace_confirmation()); service.finish_append( replace_session, OtpInput::line(replacement_uri.as_bytes().to_vec())?, OverwriteDecision::Decline, None, &mut committer, )?; assert_eq!( decrypt(&repository, &keys, "otp/totp", &mut provider)?.expose(), format!("fixture-password\n{replacement_uri}\n").as_bytes() ); assert_eq!( committer.changes.last().expect("replace commit").message(), "Replace OTP secret for otp/totp." ); let hotp_path = EntryPath::parse("otp/hotp")?; let hotp_before = repository.read_entry(&hotp_path)?; let hotp = service.code("otp/hotp", 0, None, &mut provider, &mut committer)?; assert_eq!(hotp.counter(), Some(1)); assert_eq!(hotp.code().expose().len(), 8); assert_eq!( committer.changes.last().expect("HOTP commit").message(), "Increment HOTP counter for otp/hotp." ); let hotp_plaintext = keys.decrypt(&repository.read_entry(&hotp_path)?, &mut provider)?; assert!( hotp_plaintext .expose() .windows(9) .any(|part| part == b"counter=1") ); assert_ne!(repository.read_entry(&hotp_path)?, hotp_before); let inserted_uri = "otpauth://totp/New:alice?secret=JBSWY3DPEHPK3PXP&issuer=New"; let insert = OtpInsertRequest { entry: Some("otp/new".to_owned()), force: false, echo: false, source: OtpInputSource::Uri, }; let plan = service.prepare_insert(&insert, OtpInput::line(inserted_uri.as_bytes().to_vec())?)?; assert!(!plan.requires_path_confirmation()); assert!(!plan.requires_overwrite_confirmation()); service.finish_insert( plan, OverwriteDecision::Allow, OverwriteDecision::Allow, None, &mut committer, )?; assert_eq!( decrypt(&repository, &keys, "otp/new", &mut provider)?.expose(), format!("{inserted_uri}\n").as_bytes() ); let derived = OtpInsertRequest { entry: None, force: false, echo: false, source: OtpInputSource::Secret { issuer: Some("Issuer".to_owned()), account: Some("account".to_owned()), }, }; let plan = service.prepare_insert( &derived, OtpInput::hidden(b"JBSWY3DPEHPK3PXP".to_vec(), b"JBSWY3DPEHPK3PXP".to_vec())?, )?; assert_eq!(plan.path().to_string(), "Issuer/account"); assert!(plan.requires_path_confirmation()); service.finish_insert( plan, OverwriteDecision::Allow, OverwriteDecision::Allow, None, &mut committer, )?; assert_eq!( decrypt(&repository, &keys, "Issuer/account", &mut provider)?.expose(), b"otpauth://totp/Issuer:account?secret=JBSWY3DPEHPK3PXP&issuer=Issuer\n" ); let original = decrypt(&repository, &keys, "email/personal", &mut provider)?; let append = OtpAppendRequest { entry: "email/personal".to_owned(), force: false, echo: false, source: OtpInputSource::Uri, }; let session = service.begin_append(&append, &mut provider)?; assert!(!session.requires_replace_confirmation()); service.finish_append( session, OtpInput::line(inserted_uri.as_bytes().to_vec())?, OverwriteDecision::Allow, None, &mut committer, )?; let appended = decrypt(&repository, &keys, "email/personal", &mut provider)?; assert!(appended.expose().starts_with(original.expose())); assert!( appended .expose() .ends_with(format!("{inserted_uri}\n").as_bytes()) ); assert_eq!( committer.changes.last().expect("append commit").message(), "Append OTP secret for email/personal." ); Ok(()) } #[test] fn malformed_ambiguous_declined_and_commit_failures_never_mutate() -> 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 = OtpService::new(&repository, &keys); let mut provider = FixtureSecrets::all(&fixture); let mut committer = Committer::default(); let path = EntryPath::parse("otp/totp")?; let original = repository.read_entry(&path)?; let request = OtpAppendRequest { entry: "otp/totp".to_owned(), force: false, echo: false, source: OtpInputSource::Uri, }; let session = service.begin_append(&request, &mut provider)?; assert!(session.requires_replace_confirmation()); assert!(matches!( service.finish_append( session, OtpInput::line(b"otpauth://totp/new?secret=JBSWY3DPEHPK3PXP".to_vec())?, OverwriteDecision::Decline, None, &mut committer, ), Err(OtpError::Cancelled) )); assert_eq!(repository.read_entry(&path)?, original); assert!(committer.changes.is_empty()); let forced = OtpAppendRequest { force: true, ..request.clone() }; let first = service.begin_append(&forced, &mut provider)?; let stale = service.begin_append(&forced, &mut provider)?; service.finish_append( first, OtpInput::line(b"otpauth://totp/first?secret=JBSWY3DPEHPK3PXP".to_vec())?, OverwriteDecision::Allow, None, &mut committer, )?; let committed = repository.read_entry(&path)?; assert!(matches!( service.finish_append( stale, OtpInput::line(b"otpauth://totp/stale?secret=JBSWY3DPEHPK3PXP".to_vec())?, OverwriteDecision::Allow, None, &mut committer, ), Err(OtpError::ConcurrentModification { .. }) )); assert_eq!(repository.read_entry(&path)?, committed); assert_eq!(committer.changes.len(), 1); let non_otp = EntryPath::parse("email/personal")?; let non_otp_before = repository.read_entry(&non_otp)?; assert!(matches!( service.uri("email/personal", &mut provider), Err(OtpError::MissingUri { .. }) )); assert_eq!(repository.read_entry(&non_otp)?, non_otp_before); assert_eq!(committer.changes.len(), 1); let hotp = EntryPath::parse("otp/hotp")?; let hotp_before = repository.read_entry(&hotp)?; committer.fail = true; assert!(matches!( service.code("otp/hotp", 0, None, &mut provider, &mut committer), Err(OtpError::Commit(_)) )); assert!( decrypt(&repository, &keys, "otp/hotp", &mut provider)? .expose() .windows(9) .any(|part| part == b"counter=0") ); assert_eq!(service.uri("otp/hotp", &mut provider)?.counter(), Some(0)); assert_eq!(repository.read_entry(&hotp)?, hotp_before); let duplicate_path = EntryPath::parse("otp/duplicate")?; let duplicate_plaintext = SecretBytes::new( b"password\notpauth://totp/one?secret=JBSWY3DPEHPK3PXP\notpauth://totp/two?secret=JBSWY3DPEHPK3PXP\n" .to_vec(), ); let recipients = RecipientPolicyManager::new(&repository, &keys).resolve_for_entry(&duplicate_path, None)?; repository.write_entry( &duplicate_path, &keys.encrypt(duplicate_plaintext, recipients.recipients())?, )?; let duplicate_before = repository.read_entry(&duplicate_path)?; let duplicate_request = OtpAppendRequest { entry: "otp/duplicate".to_owned(), force: true, echo: true, source: OtpInputSource::Uri, }; assert!(matches!( service.begin_append(&duplicate_request, &mut provider), Err(OtpError::AmbiguousUri { .. }) )); assert_eq!(repository.read_entry(&duplicate_path)?, duplicate_before); Ok(()) } #[test] fn automatic_code_supports_pass_diff_config_and_opens_git_only_for_hotp() -> TestResult { let fixture = FixtureSet::load()?; let store = fixture.materialize_store("basic")?; let repository = Repository::open(store.path())?; let identity = GitIdentity::ironstorage(); let git = GitRepository::init(&repository, identity.clone())?; let initial_commits = git.log(None)?.len(); drop(git); let config_path = store.path().join(".git/config"); let mut config = fs::OpenOptions::new().append(true).open(&config_path)?; config.write_all(&fixture.read("pass-git-config.ini")?)?; drop(config); let original_config = fs::read(&config_path)?; let keys = KeyStore::load(fixture.path("keys"))?; let service = OtpService::new(&repository, &keys); let mut provider = FixtureSecrets::all(&fixture); let totp_path = EntryPath::parse("otp/totp")?; let totp_before = repository.read_entry(&totp_path)?; let totp = service.code_automatic("otp/totp", 59, None, &mut provider)?; assert_eq!(totp.counter(), None); assert_eq!(totp.valid_until(), Some(60)); assert_eq!( totp.validity(), OtpCodeValidity::Timed { valid_until: 60, period: 30, } ); assert_eq!(totp.validity().period(), Some(30)); assert_eq!(totp.remaining_at(59), Some(1)); assert_eq!(totp.remaining_at(60), Some(0)); assert_eq!(repository.read_entry(&totp_path)?, totp_before); let git = GitRepository::open(&repository, identity.clone())?; assert_eq!(git.log(None)?.len(), initial_commits); drop(git); let hotp = service.code_automatic("otp/hotp", 0, None, &mut provider)?; assert_eq!(hotp.counter(), Some(1)); assert_eq!(hotp.valid_until(), None); assert_eq!( hotp.validity(), OtpCodeValidity::CounterBased { counter: 1 } ); assert_eq!(hotp.remaining_at(0), None); assert_eq!(hotp.validity().period(), None); assert_eq!(service.uri("otp/hotp", &mut provider)?.counter(), Some(1)); let git = GitRepository::open(&repository, identity)?; assert_eq!(git.log(None)?.len(), initial_commits + 1); assert_eq!( git.log(Some(1))?[0].message(), "Increment HOTP counter for otp/hotp." ); assert_eq!(fs::read(config_path)?, original_config); Ok(()) } #[test] fn desktop_otp_boundary_uses_rfc_codes_and_commits_import_replace_and_removal() -> 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 recipients = RecipientPolicyManager::new(&repository, &keys) .resolve_for_entry(&EntryPath::parse("desktop/rfc-totp")?, None)?; let secret = BASE32_NOPAD.encode(b"12345678901234567890"); for (entry, plaintext) in [ ( "desktop/rfc-totp", format!( "password\notpauth://totp/RFC6238?secret={secret}&algorithm=SHA1&digits=8&period=30\n" ), ), ( "desktop/rfc-hotp", format!("password\notpauth://hotp/RFC4226?secret={secret}&counter=0\n"), ), ] { repository.write_entry( &EntryPath::parse(entry)?, &keys.encrypt( SecretBytes::new(plaintext.into_bytes()), recipients.recipients(), )?, )?; } GitRepository::init(&repository, GitIdentity::ironstorage())?; let config_path = store.path().join("desktop-config.toml"); fs::write( &config_path, format!( "vault = {:?}\ndefault_key = {:?}\nkey_material = {:?}\n", store.path(), fixture.generated.keys[0].primary_fingerprint, fixture.path("keys"), ), )?; let desktop = DesktopStorage::load(Some(&config_path))?; let mut provider = FixtureSecrets::all(&fixture); let totp = desktop.otp_code("desktop/rfc-totp", 59, false, &mut provider)?; assert_eq!(totp.code().expose(), b"94287082"); assert_eq!( totp.validity(), OtpCodeValidity::Timed { valid_until: 60, period: 30, } ); assert_eq!(totp.metadata().kind(), OtpKind::Totp); assert_eq!(totp.metadata().digits(), 8); let hotp_path = EntryPath::parse("desktop/rfc-hotp")?; let before = repository.read_entry(&hotp_path)?; let denied = match desktop.otp_code("desktop/rfc-hotp", 59, false, &mut provider) { Ok(_) => panic!("HOTP requires confirmation"), Err(error) => error, }; assert_eq!(denied.kind(), DesktopErrorKind::Otp); assert_eq!(repository.read_entry(&hotp_path)?, before); let hotp = desktop.otp_code("desktop/rfc-hotp", 59, true, &mut provider)?; assert_eq!(hotp.code().expose(), b"287082"); assert_eq!( hotp.validity(), OtpCodeValidity::CounterBased { counter: 1 } ); let (_, _, _, tree, document) = hotp.into_parts(); assert!(tree.is_some()); assert_eq!( document .expect("HOTP returns refreshed document") .fields() .iter() .find_map(|field| field.metadata().otp()) .and_then(|otp| otp.counter()), Some(1) ); let replacement = SecretBytes::new( b"otpauth://totp/Replaced:alice?secret=JBSWY3DPEHPK3PXP&issuer=Replaced".to_vec(), ); let before = repository.read_entry(&EntryPath::parse("desktop/rfc-totp")?)?; assert!( desktop .import_otp( "desktop/rfc-totp", SecretBytes::new(replacement.expose().to_vec()), OverwriteDecision::Decline, &mut provider, ) .is_err() ); assert_eq!( repository.read_entry(&EntryPath::parse("desktop/rfc-totp")?)?, before ); assert!( desktop .import_otp_qr( "desktop/rfc-totp", SecretBytes::new(b"not an image".to_vec()), OverwriteDecision::Allow, &mut provider, ) .is_err() ); assert_eq!( repository.read_entry(&EntryPath::parse("desktop/rfc-totp")?)?, before ); let replaced = desktop.import_otp( "desktop/rfc-totp", replacement, OverwriteDecision::Allow, &mut provider, )?; assert_eq!( replaced .into_parts() .1 .fields() .iter() .find_map(|field| field.metadata().otp()) .and_then(|otp| otp.issuer()), Some("Replaced") ); let removed = desktop.remove_otp("desktop/rfc-totp", &mut provider)?; assert!( removed .into_parts() .1 .fields() .iter() .all(|field| field.metadata().kind() != ironstorage::document::EntryFieldKind::OtpUri) ); Ok(()) } #[test] fn read_only_totp_ignores_git_that_mutations_correctly_reject() -> TestResult { let fixture = FixtureSet::load()?; let store = fixture.materialize_store("basic")?; let repository = Repository::open(store.path())?; GitRepository::init(&repository, GitIdentity::ironstorage())?; let config_path = store.path().join(".git/config"); let mut config = fs::OpenOptions::new().append(true).open(&config_path)?; config.write_all(b"[filter \"unsafe\"]\n\tclean = external-helper\n")?; drop(config); let keys = KeyStore::load(fixture.path("keys"))?; let service = OtpService::new(&repository, &keys); let mut provider = FixtureSecrets::all(&fixture); let totp_path = EntryPath::parse("otp/totp")?; let totp_before = repository.read_entry(&totp_path)?; assert!( service .code_automatic("otp/totp", 59, None, &mut provider) .is_ok() ); assert_eq!(repository.read_entry(&totp_path)?, totp_before); let hotp_path = EntryPath::parse("otp/hotp")?; let hotp_before = repository.read_entry(&hotp_path)?; let head_before = fs::read(store.path().join(".git/refs/heads/main"))?; assert!(matches!( service.code_automatic("otp/hotp", 0, None, &mut provider), Err(OtpError::Git(_)) )); assert_eq!(repository.read_entry(&hotp_path)?, hotp_before); assert_eq!( fs::read(store.path().join(".git/refs/heads/main"))?, head_before ); Ok(()) } fn token(secret: &[u8], algorithm: &str) -> Result { OtpUri::parse_str(&format!( "otpauth://totp/RFC6238?secret={}&algorithm={algorithm}&digits=8&period=30", BASE32_NOPAD.encode(secret) )) } fn decrypt( repository: &Repository, keys: &KeyStore, path: &str, provider: &mut impl SecretProvider, ) -> Result> { Ok(keys.decrypt(&repository.read_entry(&EntryPath::parse(path)?)?, provider)?) }