545 lines
19 KiB
Rust
545 lines
19 KiB
Rust
#![forbid(unsafe_code)]
|
|
|
|
mod support;
|
|
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
|
|
use ironstorage::{
|
|
crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError},
|
|
document::{
|
|
DocumentError, EntryDocumentService, EntryFieldDraft, EntryFieldKind, EntrySensitivity,
|
|
},
|
|
recipient::RecipientPolicyManager,
|
|
repository::{EntryPath, Repository, SecretBytes},
|
|
write::{EntryCommit, EntryCommitError, EntryCommitter, WriteError},
|
|
};
|
|
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 commit failure"))
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn complex_documents_round_trip_with_storage_owned_metadata() -> 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 mut secrets = FixtureSecrets::all(&fixture);
|
|
let plaintext = b"p\xc3\xa4ssw\xc3\xb6rd\r\nusername: alice\r\nautoType_enabled: true\r\nicon: Internet\r\ncustom: one\r\ncustom: \r\notpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example\r\n\r\nfirst note\r\nsecond note\r\n\xe9\x8d\xb5: \xe5\x80\xbc\r\nunrecognized line";
|
|
write_plaintext(&repository, &keys, "documents/complex", plaintext)?;
|
|
|
|
let service = EntryDocumentService::new(&repository, &keys);
|
|
let document = service.open("documents/complex", &mut secrets)?;
|
|
assert_eq!(document.serialize().expose(), plaintext);
|
|
assert_eq!(
|
|
document.password().expect("password").value(),
|
|
"pässwörd".as_bytes()
|
|
);
|
|
assert_eq!(document.fields().len(), 9);
|
|
|
|
let kinds = document
|
|
.fields()
|
|
.iter()
|
|
.map(|field| field.metadata().kind())
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(
|
|
kinds,
|
|
[
|
|
EntryFieldKind::Password,
|
|
EntryFieldKind::Username,
|
|
EntryFieldKind::Field,
|
|
EntryFieldKind::Field,
|
|
EntryFieldKind::Field,
|
|
EntryFieldKind::Field,
|
|
EntryFieldKind::OtpUri,
|
|
EntryFieldKind::Note,
|
|
EntryFieldKind::Field,
|
|
]
|
|
);
|
|
assert_eq!(document.fields()[1].metadata().name(), Some("username"));
|
|
assert_eq!(
|
|
document.fields()[1].metadata().sensitivity(),
|
|
EntrySensitivity::Ordinary
|
|
);
|
|
for (index, name, value) in [
|
|
(2, "autoType_enabled", b"true".as_slice()),
|
|
(3, "icon", b"Internet".as_slice()),
|
|
] {
|
|
assert_eq!(document.fields()[index].metadata().name(), Some(name));
|
|
assert_eq!(document.fields()[index].value(), value);
|
|
assert_eq!(
|
|
document.fields()[index].metadata().sensitivity(),
|
|
EntrySensitivity::Ordinary
|
|
);
|
|
}
|
|
assert_eq!(document.fields()[4].metadata().name(), Some("custom"));
|
|
assert_eq!(document.fields()[4].value(), b"one");
|
|
assert_eq!(document.fields()[5].metadata().name(), Some("custom"));
|
|
assert!(document.fields()[5].value().is_empty());
|
|
assert_ne!(document.fields()[4].id(), document.fields()[5].id());
|
|
assert_eq!(document.fields()[8].metadata().name(), Some("鍵"));
|
|
assert_eq!(
|
|
document.fields()[8].value(),
|
|
"值\r\nunrecognized line".as_bytes()
|
|
);
|
|
assert_eq!(
|
|
document.fields()[7].value(),
|
|
b"\r\nfirst note\r\nsecond note"
|
|
);
|
|
assert_eq!(
|
|
document.fields()[6].metadata().sensitivity(),
|
|
EntrySensitivity::Sensitive
|
|
);
|
|
let otp = document.fields()[6]
|
|
.metadata()
|
|
.otp()
|
|
.expect("validated OTP metadata");
|
|
assert_eq!(otp.kind(), ironstorage::otp::OtpKind::Totp);
|
|
assert_eq!(otp.issuer(), Some("Example"));
|
|
assert_eq!(otp.account(), "alice");
|
|
assert_eq!(otp.algorithm(), ironstorage::otp::OtpAlgorithm::Sha1);
|
|
assert_eq!(otp.digits(), 6);
|
|
assert_eq!(otp.period(), Some(30));
|
|
assert_eq!(otp.counter(), None);
|
|
let copied = document.copy_field_value(document.fields()[4].id())?;
|
|
assert_eq!(copied.expose(), b"one");
|
|
assert!(!format!("{document:?}").contains("pässwörd"));
|
|
assert!(!format!("{:?}", document.fields()[6]).contains("JBSWY3DPEHPK3PXP"));
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn named_multiline_fields_are_one_lossless_logical_field() -> 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 mut secrets = FixtureSecrets::all(&fixture);
|
|
let plaintext = b"password\ncomments: Recovery codes:\none\ntwo\nurl: https://example.test\n";
|
|
write_plaintext(&repository, &keys, "documents/multiline", plaintext)?;
|
|
|
|
let service = EntryDocumentService::new(&repository, &keys);
|
|
let mut document = service.open("documents/multiline", &mut secrets)?;
|
|
assert_eq!(document.serialize().expose(), plaintext);
|
|
assert_eq!(document.fields().len(), 3);
|
|
let comments = &document.fields()[1];
|
|
assert_eq!(comments.metadata().name(), Some("comments"));
|
|
assert_eq!(comments.value(), b"Recovery codes:\none\ntwo");
|
|
assert_eq!(
|
|
document.copy_field_value(comments.id())?.expose(),
|
|
b"Recovery codes:\none\ntwo"
|
|
);
|
|
|
|
document.replace_field_value(comments.id(), b"New heading\nalpha\nbeta".to_vec())?;
|
|
assert_eq!(
|
|
document.serialize().expose(),
|
|
b"password\ncomments: New heading\n alpha\n beta\nurl: https://example.test\n"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn only_whitespace_free_names_end_multiline_fields() -> 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 mut secrets = FixtureSecrets::all(&fixture);
|
|
let plaintext = "password\ncomments: PIN: 5678\nVertragsnummer 1234\n\nwww.bahn.de Login:\n\nbenutzer / passwort\n\n(über Geschäftskundenlogin gehen!)\n\nautotype_enabled: True\nicon: 0\n";
|
|
write_plaintext(
|
|
&repository,
|
|
&keys,
|
|
"documents/comments",
|
|
plaintext.as_bytes(),
|
|
)?;
|
|
|
|
let document =
|
|
EntryDocumentService::new(&repository, &keys).open("documents/comments", &mut secrets)?;
|
|
assert_eq!(document.serialize().expose(), plaintext.as_bytes());
|
|
assert_eq!(document.fields().len(), 4);
|
|
assert_eq!(document.fields()[1].metadata().name(), Some("comments"));
|
|
assert_eq!(
|
|
document.fields()[1].value(),
|
|
"PIN: 5678\nVertragsnummer 1234\n\nwww.bahn.de Login:\n\nbenutzer / passwort\n\n(über Geschäftskundenlogin gehen!)\n"
|
|
.as_bytes()
|
|
);
|
|
assert_eq!(
|
|
document.fields()[2].metadata().name(),
|
|
Some("autotype_enabled")
|
|
);
|
|
assert_eq!(document.fields()[3].metadata().name(), Some("icon"));
|
|
assert!(matches!(
|
|
EntryFieldDraft::field("www.bahn.de Login", Vec::new()),
|
|
Err(DocumentError::InvalidFieldName)
|
|
));
|
|
|
|
write_plaintext(
|
|
&repository,
|
|
&keys,
|
|
"documents/plain-note",
|
|
b"password\n\nordinary note\ncontinued",
|
|
)?;
|
|
let note =
|
|
EntryDocumentService::new(&repository, &keys).open("documents/plain-note", &mut secrets)?;
|
|
assert_eq!(note.password().expect("password").value(), b"password");
|
|
assert_eq!(note.fields().len(), 2);
|
|
assert_eq!(note.fields()[1].value(), b"\nordinary note\ncontinued");
|
|
|
|
let unordered = "password\nzeta: last\nnote: named\ncomments: heading\notpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example\nordinary note\nurl: https://example.test\nlogin: alice\nalpha: first\n";
|
|
write_plaintext(
|
|
&repository,
|
|
&keys,
|
|
"documents/unordered",
|
|
unordered.as_bytes(),
|
|
)?;
|
|
let mut unordered_document =
|
|
EntryDocumentService::new(&repository, &keys).open("documents/unordered", &mut secrets)?;
|
|
assert_eq!(
|
|
unordered_document.serialize().expose(),
|
|
unordered.as_bytes()
|
|
);
|
|
assert_eq!(
|
|
unordered_document
|
|
.display_fields()
|
|
.into_iter()
|
|
.map(|field| field.metadata().name().unwrap_or("note"))
|
|
.collect::<Vec<_>>(),
|
|
[
|
|
"login", "password", "url", "otp", "note", "note", "comments", "alpha", "zeta"
|
|
]
|
|
);
|
|
let comments = unordered_document
|
|
.fields()
|
|
.iter()
|
|
.find(|field| field.metadata().name() == Some("comments"))
|
|
.expect("comments")
|
|
.id();
|
|
unordered_document
|
|
.replace_field_value(comments, b"heading\nline: still a comment\n\nend".to_vec())?;
|
|
assert_eq!(
|
|
unordered_document.serialize().expose(),
|
|
b"password\nzeta: last\nnote: named\ncomments: heading\n line: still a comment\n \n end\notpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example\n ordinary note\nurl: https://example.test\nlogin: alice\nalpha: first\n"
|
|
);
|
|
let normalized = unordered_document.serialize();
|
|
write_plaintext(
|
|
&repository,
|
|
&keys,
|
|
"documents/normalized",
|
|
normalized.expose(),
|
|
)?;
|
|
let normalized_document =
|
|
EntryDocumentService::new(&repository, &keys).open("documents/normalized", &mut secrets)?;
|
|
assert_eq!(
|
|
normalized_document
|
|
.fields()
|
|
.iter()
|
|
.find(|field| field.metadata().name() == Some("comments"))
|
|
.expect("normalized comments")
|
|
.value(),
|
|
b"heading\nline: still a comment\n\nend"
|
|
);
|
|
assert_eq!(
|
|
normalized_document
|
|
.fields()
|
|
.iter()
|
|
.find(|field| field.metadata().kind() == EntryFieldKind::Note)
|
|
.expect("normalized note")
|
|
.value(),
|
|
b"ordinary note"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn pass_otp_only_entries_keep_typed_metadata_in_the_first_line() -> 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 mut secrets = FixtureSecrets::all(&fixture);
|
|
write_plaintext(
|
|
&repository,
|
|
&keys,
|
|
"documents/otp-only",
|
|
b"otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example\n",
|
|
)?;
|
|
let document =
|
|
EntryDocumentService::new(&repository, &keys).open("documents/otp-only", &mut secrets)?;
|
|
assert_eq!(document.fields().len(), 1);
|
|
assert_eq!(
|
|
document.fields()[0].metadata().kind(),
|
|
EntryFieldKind::OtpUri
|
|
);
|
|
assert_eq!(
|
|
document.fields()[0]
|
|
.metadata()
|
|
.otp()
|
|
.and_then(|otp| otp.issuer()),
|
|
Some("Example")
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn field_ids_survive_updates_removal_and_reordering() -> 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 mut secrets = FixtureSecrets::all(&fixture);
|
|
write_plaintext(
|
|
&repository,
|
|
&keys,
|
|
"documents/editable",
|
|
b"password\nusername: alice\nnote: one\nnote: two\n",
|
|
)?;
|
|
let service = EntryDocumentService::new(&repository, &keys);
|
|
let mut document = service.open("documents/editable", &mut secrets)?;
|
|
let password_id = document.fields()[0].id();
|
|
let username_id = document.fields()[1].id();
|
|
let note_one_id = document.fields()[2].id();
|
|
let note_two_id = document.fields()[3].id();
|
|
let token = document.conflict_token();
|
|
|
|
document.update(
|
|
username_id,
|
|
EntryFieldDraft::field("username", b"bob".to_vec())?,
|
|
)?;
|
|
let email_id = document.add(
|
|
2,
|
|
EntryFieldDraft::field("email", "bob@例.test".as_bytes().to_vec())?,
|
|
)?;
|
|
document.reorder(note_two_id, 2)?;
|
|
let removed = document.remove(note_one_id)?;
|
|
assert_eq!(removed.id(), note_one_id);
|
|
assert_eq!(document.conflict_token(), token);
|
|
assert_eq!(document.fields()[0].id(), password_id);
|
|
assert_eq!(
|
|
document.field(username_id).expect("username").value(),
|
|
b"bob"
|
|
);
|
|
assert_eq!(
|
|
document.field(email_id).expect("email").metadata().kind(),
|
|
EntryFieldKind::Email
|
|
);
|
|
assert_eq!(document.fields()[2].id(), note_two_id);
|
|
assert_eq!(
|
|
document.serialize().expose(),
|
|
"password\nusername: bob\nnote: two\nemail: bob@例.test\n".as_bytes()
|
|
);
|
|
|
|
let mut empty = service.open("documents/missing", &mut secrets)?;
|
|
assert!(empty.fields().is_empty());
|
|
assert!(empty.password().is_none());
|
|
empty.add(0, EntryFieldDraft::line(b"password".to_vec())?)?;
|
|
empty.add(1, EntryFieldDraft::blank())?;
|
|
assert_eq!(empty.fields().len(), 2);
|
|
assert_eq!(empty.fields()[1].metadata().kind(), EntryFieldKind::Note);
|
|
assert_eq!(empty.serialize().expose(), b"password\n ");
|
|
|
|
assert!(matches!(
|
|
EntryFieldDraft::line(b"two\nlines".to_vec()),
|
|
Err(DocumentError::LineBreak)
|
|
));
|
|
assert!(matches!(
|
|
EntryFieldDraft::field(" invalid ", Vec::new()),
|
|
Err(DocumentError::InvalidFieldName)
|
|
));
|
|
assert!(matches!(
|
|
EntryFieldDraft::otp_uri(b"otpauth://totp/missing-secret".to_vec()),
|
|
Err(DocumentError::InvalidOtpUri)
|
|
));
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn atomic_save_creates_updates_and_rejects_stale_documents() -> 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 mut secrets = FixtureSecrets::all(&fixture);
|
|
let service = EntryDocumentService::new(&repository, &keys);
|
|
let mut committer = Committer::default();
|
|
|
|
let mut created = service.open("documents/new", &mut secrets)?;
|
|
created.add(0, EntryFieldDraft::line(b"new password".to_vec())?)?;
|
|
created.add(1, EntryFieldDraft::field("username", b"alice".to_vec())?)?;
|
|
service.save(created, None, &mut committer)?;
|
|
assert_eq!(committer.changes.len(), 1);
|
|
assert_eq!(
|
|
decrypt(&repository, &keys, "documents/new", &mut secrets)?.expose(),
|
|
b"new password\nusername: alice"
|
|
);
|
|
|
|
let mut first = service.open("documents/new", &mut secrets)?;
|
|
let mut stale = service.open("documents/new", &mut secrets)?;
|
|
assert_eq!(first.conflict_token(), stale.conflict_token());
|
|
let first_password = first.password().expect("password").id();
|
|
first.update(first_password, EntryFieldDraft::line(b"winner".to_vec())?)?;
|
|
service.save(first, None, &mut committer)?;
|
|
let winner = repository.read_entry(&EntryPath::parse("documents/new")?)?;
|
|
|
|
let stale_password = stale.password().expect("password").id();
|
|
stale.update(stale_password, EntryFieldDraft::line(b"stale".to_vec())?)?;
|
|
assert!(matches!(
|
|
service.save_recoverable(&stale, None, &mut committer),
|
|
Err(DocumentError::Write(
|
|
WriteError::ConcurrentModification { .. }
|
|
))
|
|
));
|
|
assert_eq!(stale.serialize().expose(), b"stale\nusername: alice");
|
|
assert_eq!(
|
|
repository.read_entry(&EntryPath::parse("documents/new")?)?,
|
|
winner
|
|
);
|
|
|
|
let mut rollback = service.open("documents/new", &mut secrets)?;
|
|
let password = rollback.password().expect("password").id();
|
|
rollback.update(password, EntryFieldDraft::line(b"must roll back".to_vec())?)?;
|
|
committer.fail = true;
|
|
assert!(matches!(
|
|
service.save_recoverable(&rollback, None, &mut committer),
|
|
Err(DocumentError::Write(WriteError::Commit(_)))
|
|
));
|
|
assert!(
|
|
rollback
|
|
.serialize()
|
|
.expose()
|
|
.starts_with(b"must roll back\n")
|
|
);
|
|
assert_eq!(
|
|
repository.read_entry(&EntryPath::parse("documents/new")?)?,
|
|
winner
|
|
);
|
|
committer.fail = false;
|
|
std::fs::write(
|
|
store.path().join(".gpg-id"),
|
|
b"FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF\n",
|
|
)?;
|
|
let retained = rollback.serialize();
|
|
assert!(
|
|
service
|
|
.save_recoverable(&rollback, None, &mut committer)
|
|
.is_err()
|
|
);
|
|
assert_eq!(rollback.serialize().expose(), retained.expose());
|
|
assert_eq!(
|
|
repository.read_entry(&EntryPath::parse("documents/new")?)?,
|
|
winner
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn every_compatibility_entry_round_trips_as_a_document() -> TestResult {
|
|
let fixture = FixtureSet::load()?;
|
|
let stores = fixture
|
|
.generated
|
|
.entries
|
|
.iter()
|
|
.map(|entry| entry.store.as_str())
|
|
.collect::<BTreeSet<_>>();
|
|
for store_id in stores {
|
|
let store = fixture.materialize_store(store_id)?;
|
|
let repository = Repository::open(store.path())?;
|
|
let keys = KeyStore::load(fixture.path("keys"))?;
|
|
let mut secrets = FixtureSecrets::all(&fixture);
|
|
let service = EntryDocumentService::new(&repository, &keys);
|
|
for entry in fixture
|
|
.generated
|
|
.entries
|
|
.iter()
|
|
.filter(|entry| entry.store == store_id)
|
|
{
|
|
let logical = entry
|
|
.path
|
|
.strip_suffix(".gpg")
|
|
.expect("fixture entry suffix");
|
|
let document = service.open(logical, &mut secrets)?;
|
|
assert_eq!(
|
|
document.serialize().expose(),
|
|
fixture.read(
|
|
std::path::Path::new("expected")
|
|
.join(store_id)
|
|
.join(&entry.plaintext),
|
|
)?,
|
|
"document round trip for {}/{}",
|
|
store_id,
|
|
entry.path
|
|
);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn write_plaintext(
|
|
repository: &Repository,
|
|
keys: &KeyStore,
|
|
path: &str,
|
|
plaintext: &[u8],
|
|
) -> TestResult {
|
|
let path = EntryPath::parse(path)?;
|
|
let recipients =
|
|
RecipientPolicyManager::new(repository, keys).resolve_for_entry(&path, None)?;
|
|
let encrypted = keys.encrypt(
|
|
SecretBytes::new(plaintext.to_vec()),
|
|
recipients.recipients(),
|
|
)?;
|
|
repository.write_entry(&path, &encrypted)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn decrypt(
|
|
repository: &Repository,
|
|
keys: &KeyStore,
|
|
path: &str,
|
|
secrets: &mut impl SecretProvider,
|
|
) -> Result<SecretBytes, Box<dyn std::error::Error>> {
|
|
Ok(keys.decrypt(&repository.read_entry(&EntryPath::parse(path)?)?, secrets)?)
|
|
}
|