Files

711 lines
24 KiB
Rust

#![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<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,
}
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, OtpError> {
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<SecretBytes, Box<dyn std::error::Error>> {
Ok(keys.decrypt(&repository.read_entry(&EntryPath::parse(path)?)?, provider)?)
}