Implement Android Credential Manager and Autofill providers (#109)
This commit is contained in:
1 parent
316a875d63
commit
0c8366d34f
48 files changed
+4138
-78
No files matched your search
@@ -32,6 +32,8 @@ pub mod mobile;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod mobile_authentication;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod mobile_credentials;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod mobile_entry;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod mobile_home;
|
||||
|
||||
@@ -387,6 +387,8 @@ pub struct MobileAuthentication {
|
||||
backend: Arc<dyn SecretStoreBackend>,
|
||||
status: Mutex<MobileAuthenticationStatus>,
|
||||
totp_discovery: Mutex<()>,
|
||||
credential_requests: Mutex<crate::mobile_credentials::CredentialRequests>,
|
||||
credential_clock: Arc<dyn AuthenticationClock>,
|
||||
}
|
||||
|
||||
impl MobileAuthentication {
|
||||
@@ -444,7 +446,7 @@ impl MobileAuthentication {
|
||||
Arc::clone(&backend),
|
||||
SecretProtectionPolicy::current_biometry_for_openpgp(),
|
||||
config.authentication_timeout(),
|
||||
clock,
|
||||
Arc::clone(&clock),
|
||||
);
|
||||
let watch_snapshot =
|
||||
WatchSnapshotSender::load(config.source().with_file_name("watch-snapshot.toml"));
|
||||
@@ -469,9 +471,344 @@ impl MobileAuthentication {
|
||||
session,
|
||||
backend,
|
||||
totp_discovery: Mutex::new(()),
|
||||
credential_requests: Mutex::default(),
|
||||
credential_clock: clock,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn expire_credential_requests(&self) -> Result<(), MobileAuthenticationError> {
|
||||
self.credential_requests
|
||||
.lock()
|
||||
.map_err(|_| locked_error())?
|
||||
.expire(self.credential_clock.now());
|
||||
Ok(())
|
||||
}
|
||||
pub fn begin_credential_request(
|
||||
&self,
|
||||
caller: crate::mobile_credentials::CredentialCaller,
|
||||
) -> Result<crate::mobile_credentials::CredentialRequest, MobileAuthenticationError> {
|
||||
let revision = self.repository.mutation_revision().map_err(entry_error)?;
|
||||
let paths = self
|
||||
.repository
|
||||
.snapshot()
|
||||
.map_err(entry_error)?
|
||||
.entries()
|
||||
.map(|entry| entry.path().to_string())
|
||||
.collect();
|
||||
self.credential_requests
|
||||
.lock()
|
||||
.map_err(|_| locked_error())?
|
||||
.begin(&caller, revision, paths, self.credential_clock.now())
|
||||
.map_err(credential_error)
|
||||
}
|
||||
|
||||
pub fn credential_request(
|
||||
&self,
|
||||
token: &str,
|
||||
caller: &crate::mobile_credentials::CredentialCaller,
|
||||
) -> Result<crate::mobile_credentials::CredentialRequest, MobileAuthenticationError> {
|
||||
let revision = self.repository.mutation_revision().map_err(entry_error)?;
|
||||
self.credential_requests
|
||||
.lock()
|
||||
.map_err(|_| locked_error())?
|
||||
.validate(token, caller, &revision, self.credential_clock.now())
|
||||
.map_err(credential_error)
|
||||
}
|
||||
|
||||
pub fn search_credential_request(
|
||||
&self,
|
||||
token: &str,
|
||||
caller: &crate::mobile_credentials::CredentialCaller,
|
||||
query: &str,
|
||||
) -> Result<crate::mobile_credentials::CredentialRequest, MobileAuthenticationError> {
|
||||
if query.len() > 256 || query.chars().any(char::is_control) {
|
||||
return Err(credential_error("The search text is invalid."));
|
||||
}
|
||||
let mut request = self.credential_request(token, caller)?;
|
||||
let query = query.trim().to_lowercase();
|
||||
request
|
||||
.candidates
|
||||
.retain(|candidate| candidate.path.to_lowercase().contains(&query));
|
||||
request.candidates.truncate(100);
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
pub fn cancel_credential_request(&self, token: &str) -> Result<(), MobileAuthenticationError> {
|
||||
self.credential_requests
|
||||
.lock()
|
||||
.map_err(|_| locked_error())?
|
||||
.cancel(token);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn fill_credential(
|
||||
&self,
|
||||
token: &str,
|
||||
caller: &crate::mobile_credentials::CredentialCaller,
|
||||
path: &str,
|
||||
consent: bool,
|
||||
passphrase: Option<SecretBytes>,
|
||||
allowed_usernames: &[String],
|
||||
) -> Result<crate::mobile_credentials::CredentialValue, MobileAuthenticationError> {
|
||||
let request = self.credential_request(token, caller)?;
|
||||
if !consent
|
||||
|| !request
|
||||
.candidates
|
||||
.iter()
|
||||
.any(|candidate| candidate.path == path)
|
||||
{
|
||||
return Err(credential_error(
|
||||
"Select an entry and explicitly allow this caller to receive its username and password.",
|
||||
));
|
||||
}
|
||||
self.ensure_repository_idle()?;
|
||||
self.unlock_entry(path, passphrase)?;
|
||||
let document = self.open_active_document(path)?;
|
||||
let value = crate::mobile_credentials::credential_value(&document, allowed_usernames)
|
||||
.map_err(credential_error)?;
|
||||
let revision = self.repository.mutation_revision().map_err(entry_error)?;
|
||||
let mut requests = self
|
||||
.credential_requests
|
||||
.lock()
|
||||
.map_err(|_| locked_error())?;
|
||||
requests
|
||||
.validate(token, caller, &revision, self.credential_clock.now())
|
||||
.map_err(credential_error)?;
|
||||
self.ensure_active()?;
|
||||
requests.cancel(token);
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub fn stage_credential_save(
|
||||
&self,
|
||||
token: &str,
|
||||
caller: &crate::mobile_credentials::CredentialCaller,
|
||||
username: SecretBytes,
|
||||
password: SecretBytes,
|
||||
) -> Result<(), MobileAuthenticationError> {
|
||||
self.credential_request(token, caller)?;
|
||||
for value in [&username, &password] {
|
||||
let text = std::str::from_utf8(value.expose())
|
||||
.map_err(|_| credential_error("Invalid credential text."))?;
|
||||
if text.is_empty() || text.len() > 16_384 || text.chars().any(char::is_control) {
|
||||
return Err(credential_error("Invalid credential field."));
|
||||
}
|
||||
}
|
||||
let mut body = Vec::with_capacity(4 + username.expose().len() + password.expose().len());
|
||||
body.extend_from_slice(&(username.expose().len() as u32).to_be_bytes());
|
||||
body.extend_from_slice(username.expose());
|
||||
body.extend_from_slice(password.expose());
|
||||
// The chosen destination may use a different private key than the default.
|
||||
let recipients = self
|
||||
.keys
|
||||
.infos()
|
||||
.filter(|key| key.has_secret() && key.can_encrypt())
|
||||
.map(|key| self.keys.resolve(key.fingerprint().as_str()))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(key_error)?;
|
||||
let sealed = self
|
||||
.keys
|
||||
.encrypt(SecretBytes::new(body), &recipients)
|
||||
.map_err(key_error)?;
|
||||
self.credential_requests
|
||||
.lock()
|
||||
.map_err(|_| locked_error())?
|
||||
.stage(token, sealed, self.credential_clock.now())
|
||||
.map_err(credential_error)
|
||||
}
|
||||
|
||||
pub fn save_staged_credential(
|
||||
&self,
|
||||
token: &str,
|
||||
caller: &crate::mobile_credentials::CredentialCaller,
|
||||
path: &str,
|
||||
consent: bool,
|
||||
overwrite: bool,
|
||||
passphrase: Option<SecretBytes>,
|
||||
) -> Result<(), MobileAuthenticationError> {
|
||||
if !consent {
|
||||
return Err(credential_error(
|
||||
"Confirm the destination before unlocking this save request.",
|
||||
));
|
||||
}
|
||||
self.credential_request(token, caller)?;
|
||||
let sealed = self
|
||||
.credential_requests
|
||||
.lock()
|
||||
.map_err(|_| locked_error())?
|
||||
.staged(token, self.credential_clock.now())
|
||||
.map_err(credential_error)?;
|
||||
let target = EntryPath::parse(path).map_err(entry_error)?;
|
||||
let recipients = RecipientPolicyManager::new(&self.repository, &self.keys)
|
||||
.resolve_for_entry(&target, None)
|
||||
.map_err(|error| entry_detail("Recipients Are Unavailable", error))?;
|
||||
let probe = self
|
||||
.keys
|
||||
.encrypt(
|
||||
SecretBytes::new(b"IronStorage credential destination authentication".to_vec()),
|
||||
recipients.recipients(),
|
||||
)
|
||||
.map_err(key_error)?;
|
||||
self.unlock_ciphertext(&probe, passphrase)?;
|
||||
let (handle, key) = {
|
||||
let status = self.status()?;
|
||||
let active = status.active.as_ref().ok_or_else(locked_error)?;
|
||||
(active.handle.clone(), active.key.clone())
|
||||
};
|
||||
let mut provider = KeyOnlyProvider::new(handle, &key);
|
||||
let body = self
|
||||
.keys
|
||||
.decrypt(&sealed, &mut provider)
|
||||
.map_err(key_error)?;
|
||||
let header: [u8; 4] = body
|
||||
.expose()
|
||||
.get(..4)
|
||||
.ok_or_else(|| credential_error("Invalid staged credential."))?
|
||||
.try_into()
|
||||
.map_err(|_| credential_error("Invalid staged credential."))?;
|
||||
let length = u32::from_be_bytes(header) as usize;
|
||||
let username = SecretBytes::new(
|
||||
body.expose()
|
||||
.get(4..4 + length)
|
||||
.ok_or_else(|| credential_error("Invalid staged credential."))?
|
||||
.to_vec(),
|
||||
);
|
||||
let password = SecretBytes::new(
|
||||
body.expose()
|
||||
.get(4 + length..)
|
||||
.ok_or_else(|| credential_error("Invalid staged credential."))?
|
||||
.to_vec(),
|
||||
);
|
||||
self.save_credential(
|
||||
token,
|
||||
caller,
|
||||
crate::mobile_credentials::CredentialSaveDestination {
|
||||
path,
|
||||
consent,
|
||||
overwrite,
|
||||
},
|
||||
username,
|
||||
password,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn save_credential(
|
||||
&self,
|
||||
token: &str,
|
||||
caller: &crate::mobile_credentials::CredentialCaller,
|
||||
destination: crate::mobile_credentials::CredentialSaveDestination<'_>,
|
||||
username: SecretBytes,
|
||||
password: SecretBytes,
|
||||
passphrase: Option<SecretBytes>,
|
||||
) -> Result<(), MobileAuthenticationError> {
|
||||
let crate::mobile_credentials::CredentialSaveDestination {
|
||||
path,
|
||||
consent,
|
||||
overwrite,
|
||||
} = destination;
|
||||
self.credential_request(token, caller)?;
|
||||
if !consent {
|
||||
return Err(credential_error(
|
||||
"Confirm the destination before saving this credential.",
|
||||
));
|
||||
}
|
||||
let target = EntryPath::parse(path).map_err(entry_error)?;
|
||||
self.ensure_repository_idle()?;
|
||||
{
|
||||
let status = self.status()?;
|
||||
if status
|
||||
.editors
|
||||
.values()
|
||||
.any(|draft| draft.document().path() == &target)
|
||||
|| status
|
||||
.editor_checkpoints
|
||||
.values()
|
||||
.any(|checkpoint| checkpoint.document.path() == &target)
|
||||
{
|
||||
return Err(credential_error(
|
||||
"Close or discard this entry's existing editor before updating it.",
|
||||
));
|
||||
}
|
||||
}
|
||||
let exists = VaultWriter::new(&self.repository, &self.keys)
|
||||
.entry_exists(path)
|
||||
.map_err(|error| entry_detail("Destination Is Unavailable", error))?;
|
||||
if exists && !overwrite {
|
||||
return Err(credential_error(
|
||||
"This destination exists. Explicitly confirm updating it.",
|
||||
));
|
||||
}
|
||||
let editor = if exists {
|
||||
self.unlock_entry(path, passphrase)?;
|
||||
self.begin_entry_editor(path)?
|
||||
} else {
|
||||
let name = target
|
||||
.as_path()
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.ok_or_else(|| credential_error("Invalid destination."))?;
|
||||
self.begin_create_entry(
|
||||
&target.parent_directory().as_path().to_string_lossy(),
|
||||
name,
|
||||
passphrase,
|
||||
)?
|
||||
};
|
||||
let editor_id = editor.id();
|
||||
let result = (|| {
|
||||
let mut document = {
|
||||
let mut status = self.status()?;
|
||||
status
|
||||
.editors
|
||||
.remove(&editor_id)
|
||||
.ok_or_else(editor_missing)?
|
||||
.into_document()
|
||||
};
|
||||
let original_plaintext = document.serialize();
|
||||
crate::mobile_credentials::set_credential(&mut document, username, password)
|
||||
.map_err(credential_error)?;
|
||||
self.credential_request(token, caller)?;
|
||||
self.ensure_active()?;
|
||||
if exists && document.serialize().expose() == original_plaintext.expose() {
|
||||
let current = self.repository.read_entry(&target).map_err(entry_error)?;
|
||||
if Some(¤t) != document.original_ciphertext() {
|
||||
return Err(credential_error(
|
||||
"The destination changed during this save.",
|
||||
));
|
||||
}
|
||||
self.credential_request(token, caller)?;
|
||||
self.ensure_active()?;
|
||||
return Ok(());
|
||||
}
|
||||
self.reserve_entry_mutation()?;
|
||||
let result = (|| {
|
||||
let mut commit = AutomaticEntryCommitter::for_entry(
|
||||
&self.repository,
|
||||
path,
|
||||
self.git_identity()?,
|
||||
)
|
||||
.map_err(|error| entry_detail("Credential Could Not Be Saved", error))?;
|
||||
let mut guarded = CredentialCommitter {
|
||||
authentication: self,
|
||||
token,
|
||||
commit: &mut commit,
|
||||
};
|
||||
EntryDocumentService::new(&self.repository, &self.keys)
|
||||
.save_recoverable(&document, None, &mut guarded)
|
||||
.map_err(document_error)?;
|
||||
self.reconcile_saved_document(&document);
|
||||
Ok(())
|
||||
})();
|
||||
self.release_entry_mutation();
|
||||
result
|
||||
})();
|
||||
let _ = self.discard_entry_editor(editor_id);
|
||||
if result.is_ok() {
|
||||
self.cancel_credential_request(token)?;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Validate an entry unlock and retain only the key passphrase for the shared lease.
|
||||
pub fn unlock_entry(
|
||||
&self,
|
||||
@@ -2202,6 +2539,10 @@ impl MobileAuthentication {
|
||||
}
|
||||
|
||||
pub fn manual_lock(&self) -> Result<(), MobileAuthenticationError> {
|
||||
self.credential_requests
|
||||
.lock()
|
||||
.map_err(|_| locked_error())?
|
||||
.clear();
|
||||
self.session
|
||||
.manual_lock()
|
||||
.map_err(MobileAuthenticationError::authentication)?;
|
||||
@@ -2236,6 +2577,10 @@ impl MobileAuthentication {
|
||||
}
|
||||
|
||||
pub fn cancel(&self) -> Result<(), MobileAuthenticationError> {
|
||||
self.credential_requests
|
||||
.lock()
|
||||
.map_err(|_| locked_error())?
|
||||
.clear();
|
||||
self.session
|
||||
.cancel()
|
||||
.map_err(MobileAuthenticationError::authentication)?;
|
||||
@@ -2792,6 +3137,41 @@ fn entry_detail(title: &str, error: impl fmt::Display) -> MobileAuthenticationEr
|
||||
)
|
||||
}
|
||||
|
||||
fn credential_error(detail: &str) -> MobileAuthenticationError {
|
||||
MobileAuthenticationError::new(
|
||||
MobileAuthenticationErrorKind::Entry,
|
||||
"Credential Request Rejected",
|
||||
detail,
|
||||
)
|
||||
}
|
||||
|
||||
struct CredentialCommitter<'a> {
|
||||
authentication: &'a MobileAuthentication,
|
||||
token: &'a str,
|
||||
commit: &'a mut AutomaticEntryCommitter,
|
||||
}
|
||||
impl crate::write::EntryCommitter for CredentialCommitter<'_> {
|
||||
fn commit(
|
||||
&mut self,
|
||||
change: &crate::write::EntryCommit,
|
||||
) -> Result<(), crate::write::EntryCommitError> {
|
||||
self.authentication.ensure_active().map_err(|_| {
|
||||
crate::write::EntryCommitError::new("Credential authentication expired before saving.")
|
||||
})?;
|
||||
let requests = self
|
||||
.authentication
|
||||
.credential_requests
|
||||
.lock()
|
||||
.map_err(|_| crate::write::EntryCommitError::new("Credential request cancelled."))?;
|
||||
if !requests.active(self.token, self.authentication.credential_clock.now()) {
|
||||
return Err(crate::write::EntryCommitError::new(
|
||||
"Credential request cancelled or expired.",
|
||||
));
|
||||
}
|
||||
self.commit.commit(change)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
@@ -2844,6 +3224,234 @@ mod tests {
|
||||
(root, authentication, clock)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_selection_authentication_cancellation_revision_and_timeout() {
|
||||
use crate::{mobile_credentials::CredentialCaller, repository::SecretBytes};
|
||||
let (_root, auth, clock) =
|
||||
otp_fixture("username: alice\nurl: https://example.com/\nnotes: keep me");
|
||||
let caller = CredentialCaller {
|
||||
package: "example.client".into(),
|
||||
certificates: vec!["AA".repeat(32)],
|
||||
origin: Some("https://spoofed.example/".into()),
|
||||
};
|
||||
let request = auth.begin_credential_request(caller.clone()).unwrap();
|
||||
assert_eq!(request.identity.title, "example.client");
|
||||
assert!(
|
||||
request
|
||||
.candidates
|
||||
.iter()
|
||||
.any(|candidate| candidate.path == "otp")
|
||||
);
|
||||
assert!(!auth.state().unwrap().unlocked());
|
||||
assert!(
|
||||
auth.fill_credential(&request.token, &caller, "otp", false, None, &[])
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
auth.fill_credential(&request.token, &caller, "otp", true, None, &[])
|
||||
.is_err()
|
||||
);
|
||||
let value = auth
|
||||
.fill_credential(
|
||||
&request.token,
|
||||
&caller,
|
||||
"otp",
|
||||
true,
|
||||
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(value.username.expose(), b"alice");
|
||||
assert_eq!(value.password.expose(), b"fixture-password");
|
||||
assert!(
|
||||
auth.fill_credential(&request.token, &caller, "otp", true, None, &[])
|
||||
.is_err()
|
||||
);
|
||||
let request = auth.begin_credential_request(caller.clone()).unwrap();
|
||||
assert!(
|
||||
auth.fill_credential(&request.token, &caller, "otp", true, None, &["bob".into()])
|
||||
.is_err()
|
||||
);
|
||||
auth.manual_lock().unwrap();
|
||||
assert!(auth.credential_request(&request.token, &caller).is_err());
|
||||
let request = auth.begin_credential_request(caller.clone()).unwrap();
|
||||
clock.0.store(120, std::sync::atomic::Ordering::Relaxed);
|
||||
assert!(auth.credential_request(&request.token, &caller).is_err());
|
||||
auth.expire_credential_requests().unwrap();
|
||||
assert!(auth.credential_requests.lock().unwrap().pending_is_empty());
|
||||
clock.0.store(121, std::sync::atomic::Ordering::Relaxed);
|
||||
let request = auth.begin_credential_request(caller.clone()).unwrap();
|
||||
auth.unlock_entry(
|
||||
"otp",
|
||||
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
|
||||
)
|
||||
.unwrap();
|
||||
let page = auth.entry_page("otp", 0).unwrap();
|
||||
let field = page.page().sections()[0].fields()[0].id();
|
||||
auth.replace_entry_field("otp", field, "changed".into())
|
||||
.unwrap();
|
||||
assert!(
|
||||
auth.fill_credential(&request.token, &caller, "otp", true, None, &[])
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_save_authenticates_the_destination_key_instead_of_the_default_key() {
|
||||
use crate::{
|
||||
crypto::KeyStore,
|
||||
git::{GitIdentity, GitRepository},
|
||||
mobile_credentials::CredentialCaller,
|
||||
repository::{Repository, SecretBytes},
|
||||
};
|
||||
let (_root, private, backend) = crate::mobile_onboarding::android::tests::home_fixture();
|
||||
let config = private.configuration().unwrap().unwrap();
|
||||
std::fs::write(
|
||||
config.key_material().join("bob.asc"),
|
||||
include_bytes!("../tests/fixtures/compatibility/keys/bob-secret.asc"),
|
||||
)
|
||||
.unwrap();
|
||||
let keys = KeyStore::load(config.key_material()).unwrap();
|
||||
let bob = keys
|
||||
.infos()
|
||||
.find(|key| key.user_ids().iter().any(|id| id.contains("Bob")))
|
||||
.unwrap();
|
||||
let repository = Repository::open(config.vault()).unwrap();
|
||||
std::fs::create_dir(config.vault().join("bob")).unwrap();
|
||||
std::fs::write(
|
||||
config.vault().join("bob/.gpg-id"),
|
||||
format!("{}\n", bob.fingerprint()),
|
||||
)
|
||||
.unwrap();
|
||||
GitRepository::open(&repository, GitIdentity::ironstorage())
|
||||
.unwrap()
|
||||
.commit_all("Add Bob recipient fixture")
|
||||
.unwrap();
|
||||
let auth = private
|
||||
.authentication(
|
||||
backend,
|
||||
std::sync::Arc::new(OtpClock(std::sync::atomic::AtomicU64::new(0))),
|
||||
)
|
||||
.unwrap();
|
||||
let caller = CredentialCaller {
|
||||
package: "example.client".into(),
|
||||
certificates: vec!["AA".repeat(32)],
|
||||
origin: None,
|
||||
};
|
||||
let request = auth.begin_credential_request(caller.clone()).unwrap();
|
||||
auth.stage_credential_save(
|
||||
&request.token,
|
||||
&caller,
|
||||
SecretBytes::new(b"bob".to_vec()),
|
||||
SecretBytes::new(b"destination-password".to_vec()),
|
||||
)
|
||||
.unwrap();
|
||||
auth.save_staged_credential(
|
||||
&request.token,
|
||||
&caller,
|
||||
"bob/credential",
|
||||
true,
|
||||
false,
|
||||
Some(SecretBytes::new(b"fixture-bob-passphrase".to_vec())),
|
||||
)
|
||||
.unwrap();
|
||||
let document = auth.open_active_document("bob/credential").unwrap();
|
||||
assert_eq!(
|
||||
crate::mobile_credentials::credential_value(&document, &[])
|
||||
.unwrap()
|
||||
.password
|
||||
.expose(),
|
||||
b"destination-password"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_saves_require_destination_consent_and_preserve_unrelated_fields() {
|
||||
use crate::{mobile_credentials::CredentialCaller, repository::SecretBytes};
|
||||
let (_root, auth, _clock) = otp_fixture("username: alice\nnotes: preserve this");
|
||||
let caller = CredentialCaller {
|
||||
package: "example.client".into(),
|
||||
certificates: vec!["AA".repeat(32)],
|
||||
origin: None,
|
||||
};
|
||||
let request = auth.begin_credential_request(caller.clone()).unwrap();
|
||||
auth.stage_credential_save(
|
||||
&request.token,
|
||||
&caller,
|
||||
SecretBytes::new(b"bob".to_vec()),
|
||||
SecretBytes::new(b"new-password".to_vec()),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!auth.state().unwrap().unlocked());
|
||||
assert!(
|
||||
auth.save_staged_credential(
|
||||
&request.token,
|
||||
&caller,
|
||||
"otp",
|
||||
false,
|
||||
false,
|
||||
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec()))
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(!auth.state().unwrap().unlocked());
|
||||
assert!(
|
||||
auth.save_staged_credential(
|
||||
&request.token,
|
||||
&caller,
|
||||
"otp",
|
||||
true,
|
||||
false,
|
||||
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec()))
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
auth.save_staged_credential(&request.token, &caller, "otp", true, true, None)
|
||||
.unwrap();
|
||||
let document = auth.open_active_document("otp").unwrap();
|
||||
let value = crate::mobile_credentials::credential_value(&document, &[]).unwrap();
|
||||
assert_eq!(value.username.expose(), b"bob");
|
||||
assert_eq!(value.password.expose(), b"new-password");
|
||||
assert!(
|
||||
document
|
||||
.fields()
|
||||
.iter()
|
||||
.any(|field| field.value() == b"preserve this")
|
||||
);
|
||||
let target = crate::repository::EntryPath::parse("otp").unwrap();
|
||||
let original = auth.repository.read_entry(&target).unwrap();
|
||||
let repeated = auth.begin_credential_request(caller.clone()).unwrap();
|
||||
auth.stage_credential_save(
|
||||
&repeated.token,
|
||||
&caller,
|
||||
SecretBytes::new(b"bob".to_vec()),
|
||||
SecretBytes::new(b"new-password".to_vec()),
|
||||
)
|
||||
.unwrap();
|
||||
auth.save_staged_credential(&repeated.token, &caller, "otp", true, true, None)
|
||||
.unwrap();
|
||||
assert_eq!(auth.repository.read_entry(&target).unwrap(), original);
|
||||
assert!(auth.credential_request(&repeated.token, &caller).is_err());
|
||||
let request = auth.begin_credential_request(caller.clone()).unwrap();
|
||||
auth.stage_credential_save(
|
||||
&request.token,
|
||||
&caller,
|
||||
SecretBytes::new(b"carol".to_vec()),
|
||||
SecretBytes::new(b"created-password".to_vec()),
|
||||
)
|
||||
.unwrap();
|
||||
auth.save_staged_credential(&request.token, &caller, "created", true, false, None)
|
||||
.unwrap();
|
||||
let document = auth.open_active_document("created").unwrap();
|
||||
assert_eq!(
|
||||
crate::mobile_credentials::credential_value(&document, &[])
|
||||
.unwrap()
|
||||
.username
|
||||
.expose(),
|
||||
b"carol"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn otp_presentation_matches_rfc_vectors_and_boundaries_without_renewing_the_lease() {
|
||||
use crate::{otp::OtpCodeValidity, repository::SecretBytes};
|
||||
|
||||
@@ -0,0 +1,584 @@
|
||||
//! Request-bound credentials shared by Android Credential Manager and Autofill.
|
||||
//! Callers supply OS facts; trust, matching, selection and entry parsing live here.
|
||||
|
||||
use std::{collections::BTreeMap, time::Duration};
|
||||
|
||||
use crate::{
|
||||
document::{EntryDocument, EntryFieldKind},
|
||||
repository::SecretBytes,
|
||||
};
|
||||
use rand::RngCore;
|
||||
|
||||
const REQUEST_LIFETIME: Duration = Duration::from_secs(120);
|
||||
const CHROME_RELEASE: &str = "F0FD6C5B410F25CB25C3B53346C8972FAE30F8EE7411DF910480AD6B2D60DB83";
|
||||
const FIREFOX_RELEASE: &str = "A78B62A5165B4494B2FEAD9E76A280D22D937FEE6251AECE599446B2EA319B04";
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct CredentialCaller {
|
||||
pub package: String,
|
||||
/// Current signers of the installed package, obtained from PackageManager.
|
||||
pub certificates: Vec<String>,
|
||||
pub origin: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct CredentialIdentity {
|
||||
pub title: String,
|
||||
pub binding: String,
|
||||
pub match_name: String,
|
||||
}
|
||||
|
||||
pub fn caller_identity(caller: &CredentialCaller) -> Result<CredentialIdentity, &'static str> {
|
||||
let package = &caller.package;
|
||||
if package.len() > 255
|
||||
|| !package.contains('.')
|
||||
|| package.split('.').any(|part| {
|
||||
part.is_empty()
|
||||
|| !part
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
|
||||
})
|
||||
{
|
||||
return Err("The requesting application is invalid.");
|
||||
}
|
||||
if caller.certificates.is_empty() || caller.certificates.len() > 8 {
|
||||
return Err("The requesting application's signature is unavailable.");
|
||||
}
|
||||
let mut certificates = Vec::new();
|
||||
for certificate in &caller.certificates {
|
||||
let fingerprint = certificate.replace(':', "").to_ascii_uppercase();
|
||||
if fingerprint.len() != 64 || !fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||||
return Err("The requesting application's signature is invalid.");
|
||||
}
|
||||
certificates.push(fingerprint);
|
||||
}
|
||||
certificates.sort();
|
||||
certificates.dedup();
|
||||
let privileged = match package.as_str() {
|
||||
"com.android.chrome" => certificates.as_slice() == [CHROME_RELEASE],
|
||||
"org.mozilla.firefox" => certificates.as_slice() == [FIREFOX_RELEASE],
|
||||
_ => false,
|
||||
};
|
||||
let app_binding = format!("app:{package}:{}", certificates.join(","));
|
||||
if let Some(origin) = &caller.origin {
|
||||
if origin.len() > 2048 {
|
||||
return Err("The website identity is invalid.");
|
||||
}
|
||||
// Untrusted clients cannot turn an asserted webDomain into a website permission.
|
||||
if privileged {
|
||||
let url = url::Url::parse(origin).map_err(|_| "The website identity is invalid.")?;
|
||||
if url.scheme() != "https"
|
||||
|| !url.username().is_empty()
|
||||
|| url.password().is_some()
|
||||
|| url.host_str().is_none()
|
||||
|| url.query().is_some()
|
||||
|| url.fragment().is_some()
|
||||
|| url.path() != "/"
|
||||
{
|
||||
return Err("Only a verified HTTPS website origin is supported.");
|
||||
}
|
||||
let canonical = url.origin().ascii_serialization();
|
||||
return Ok(CredentialIdentity {
|
||||
title: canonical.clone(),
|
||||
binding: format!("web:{canonical}:{app_binding}"),
|
||||
match_name: url.host_str().unwrap_or_default().to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(CredentialIdentity {
|
||||
title: package.clone(),
|
||||
binding: app_binding,
|
||||
match_name: package.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Reviewed release signing identities, consumed by AndroidX's trusted-origin verifier.
|
||||
pub fn privileged_callers_json() -> String {
|
||||
format!(
|
||||
r#"{{"apps":[{{"type":"android","info":{{"package_name":"com.android.chrome","signatures":[{{"build":"release","cert_fingerprint_sha256":"{}"}}]}}}},{{"type":"android","info":{{"package_name":"org.mozilla.firefox","signatures":[{{"build":"release","cert_fingerprint_sha256":"{}"}}]}}}}]}}"#,
|
||||
fingerprint_colons(CHROME_RELEASE),
|
||||
fingerprint_colons(FIREFOX_RELEASE)
|
||||
)
|
||||
}
|
||||
fn fingerprint_colons(value: &str) -> String {
|
||||
value
|
||||
.as_bytes()
|
||||
.chunks(2)
|
||||
.map(|chunk| std::str::from_utf8(chunk).unwrap_or_default())
|
||||
.collect::<Vec<_>>()
|
||||
.join(":")
|
||||
}
|
||||
|
||||
/// Raw AssistStructure facts, with the OS AutofillId retained only in Kotlin.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct CredentialField {
|
||||
pub index: u32,
|
||||
pub hints: Vec<String>,
|
||||
pub html_attributes: Vec<(String, String)>,
|
||||
pub input_type: u32,
|
||||
pub focused: bool,
|
||||
pub editable: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct CredentialForm {
|
||||
pub username: u32,
|
||||
pub password: u32,
|
||||
}
|
||||
|
||||
pub fn credential_form(fields: &[CredentialField]) -> Result<CredentialForm, &'static str> {
|
||||
if fields.len() > 512 {
|
||||
return Err("The client form is too large.");
|
||||
}
|
||||
let mut users = Vec::new();
|
||||
let mut passwords = Vec::new();
|
||||
let mut indexes = std::collections::BTreeSet::new();
|
||||
for field in fields {
|
||||
if !indexes.insert(field.index)
|
||||
|| field.hints.len() > 16
|
||||
|| field.hints.iter().any(|hint| hint.len() > 128)
|
||||
{
|
||||
return Err("The client form is invalid.");
|
||||
}
|
||||
if !field.editable {
|
||||
continue;
|
||||
}
|
||||
if field.html_attributes.len() > 32
|
||||
|| field
|
||||
.html_attributes
|
||||
.iter()
|
||||
.any(|(key, value)| key.len() > 128 || value.len() > 256)
|
||||
{
|
||||
return Err("The HTML form is invalid.");
|
||||
}
|
||||
let html_password = field.html_attributes.iter().any(|(key, value)| {
|
||||
(key == "type" && value == "password")
|
||||
|| (key == "autocomplete" && value == "current-password")
|
||||
});
|
||||
let html_user = field.html_attributes.iter().any(|(key, value)| {
|
||||
key == "autocomplete" && matches!(value.as_str(), "username" | "email")
|
||||
});
|
||||
let password = html_password
|
||||
|| field.hints.iter().any(|hint| hint == "password")
|
||||
|| matches!(field.input_type & 0xfff, 0x81 | 0x91 | 0xe1 | 0x12);
|
||||
let user = html_user
|
||||
|| field
|
||||
.hints
|
||||
.iter()
|
||||
.any(|hint| matches!(hint.as_str(), "username" | "emailAddress"))
|
||||
|| field.input_type & 0xfff == 0x21;
|
||||
if password && user {
|
||||
return Err("The client form has conflicting field roles.");
|
||||
}
|
||||
if password {
|
||||
passwords.push(field.index);
|
||||
}
|
||||
if user {
|
||||
users.push(field.index);
|
||||
}
|
||||
}
|
||||
match (users.as_slice(), passwords.as_slice()) {
|
||||
([username], [password]) if username != password => Ok(CredentialForm {
|
||||
username: *username,
|
||||
password: *password,
|
||||
}),
|
||||
_ => Err("Select a form with exactly one username and one password field."),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct CredentialCandidate {
|
||||
pub path: String,
|
||||
pub matched: bool,
|
||||
}
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct CredentialRequest {
|
||||
pub token: String,
|
||||
pub identity: CredentialIdentity,
|
||||
pub revision: String,
|
||||
pub candidates: Vec<CredentialCandidate>,
|
||||
}
|
||||
struct PendingCredentialRequest {
|
||||
request: CredentialRequest,
|
||||
deadline: Duration,
|
||||
save: Option<crate::repository::EncryptedEntry>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct CredentialRequests {
|
||||
pending: BTreeMap<String, PendingCredentialRequest>,
|
||||
}
|
||||
impl CredentialRequests {
|
||||
#[cfg(test)]
|
||||
pub fn pending_is_empty(&self) -> bool {
|
||||
self.pending.is_empty()
|
||||
}
|
||||
pub fn expire(&mut self, now: Duration) {
|
||||
self.pending.retain(|_, request| now < request.deadline);
|
||||
}
|
||||
pub fn stage(
|
||||
&mut self,
|
||||
token: &str,
|
||||
value: crate::repository::EncryptedEntry,
|
||||
now: Duration,
|
||||
) -> Result<(), &'static str> {
|
||||
let pending = self
|
||||
.pending
|
||||
.get_mut(token)
|
||||
.filter(|pending| now < pending.deadline)
|
||||
.ok_or("The save request expired.")?;
|
||||
pending.save = Some(value);
|
||||
Ok(())
|
||||
}
|
||||
pub fn staged(
|
||||
&self,
|
||||
token: &str,
|
||||
now: Duration,
|
||||
) -> Result<crate::repository::EncryptedEntry, &'static str> {
|
||||
let value = self
|
||||
.pending
|
||||
.get(token)
|
||||
.filter(|pending| now < pending.deadline)
|
||||
.and_then(|pending| pending.save.as_ref())
|
||||
.ok_or("The save request expired or was cancelled.")?;
|
||||
Ok(value.clone())
|
||||
}
|
||||
pub fn active(&self, token: &str, now: Duration) -> bool {
|
||||
self.pending
|
||||
.get(token)
|
||||
.is_some_and(|request| now < request.deadline)
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.pending.clear();
|
||||
}
|
||||
pub fn cancel(&mut self, token: &str) {
|
||||
self.pending.remove(token);
|
||||
}
|
||||
pub fn begin(
|
||||
&mut self,
|
||||
caller: &CredentialCaller,
|
||||
revision: String,
|
||||
paths: Vec<String>,
|
||||
now: Duration,
|
||||
) -> Result<CredentialRequest, &'static str> {
|
||||
self.pending.retain(|_, pending| now < pending.deadline);
|
||||
if self.pending.len() >= 16 {
|
||||
return Err("Too many credential requests are pending.");
|
||||
}
|
||||
let identity = caller_identity(caller)?;
|
||||
let deadline = now
|
||||
.checked_add(REQUEST_LIFETIME)
|
||||
.ok_or("The credential request deadline is invalid.")?;
|
||||
let mut random = [0; 32];
|
||||
rand::thread_rng().fill_bytes(&mut random);
|
||||
let token = data_encoding::HEXLOWER.encode(&random);
|
||||
let mut candidates: Vec<_> = paths
|
||||
.into_iter()
|
||||
.map(|path| {
|
||||
let matched = path
|
||||
.split('/')
|
||||
.any(|part| part.eq_ignore_ascii_case(&identity.match_name));
|
||||
CredentialCandidate { path, matched }
|
||||
})
|
||||
.collect();
|
||||
candidates.sort_by(|a, b| b.matched.cmp(&a.matched).then_with(|| a.path.cmp(&b.path)));
|
||||
let request = CredentialRequest {
|
||||
token: token.clone(),
|
||||
identity,
|
||||
revision,
|
||||
candidates,
|
||||
};
|
||||
self.pending.insert(
|
||||
token,
|
||||
PendingCredentialRequest {
|
||||
request: request.clone(),
|
||||
deadline,
|
||||
save: None,
|
||||
},
|
||||
);
|
||||
Ok(request)
|
||||
}
|
||||
pub fn validate(
|
||||
&self,
|
||||
token: &str,
|
||||
caller: &CredentialCaller,
|
||||
revision: &str,
|
||||
now: Duration,
|
||||
) -> Result<CredentialRequest, &'static str> {
|
||||
let pending = self
|
||||
.pending
|
||||
.get(token)
|
||||
.ok_or("This credential request was cancelled or the process restarted.")?;
|
||||
if now >= pending.deadline {
|
||||
return Err("This credential request expired. Request it again.");
|
||||
}
|
||||
if pending.request.identity != caller_identity(caller)? {
|
||||
return Err("The credential caller changed.");
|
||||
}
|
||||
if pending.request.revision != revision {
|
||||
return Err("The repository changed. Request credentials again.");
|
||||
}
|
||||
Ok(pending.request.clone())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CredentialSaveDestination<'a> {
|
||||
pub path: &'a str,
|
||||
pub consent: bool,
|
||||
pub overwrite: bool,
|
||||
}
|
||||
|
||||
/// Secret buffers are erased on drop and never included in Debug output.
|
||||
pub struct CredentialValue {
|
||||
pub username: SecretBytes,
|
||||
pub password: SecretBytes,
|
||||
}
|
||||
pub fn credential_value(
|
||||
document: &EntryDocument,
|
||||
allowed_usernames: &[String],
|
||||
) -> Result<CredentialValue, &'static str> {
|
||||
let fields = document.fields();
|
||||
let passwords: Vec<_> = fields
|
||||
.iter()
|
||||
.filter(|field| field.metadata().kind() == EntryFieldKind::Password)
|
||||
.collect();
|
||||
let usernames: Vec<_> = fields
|
||||
.iter()
|
||||
.filter(|field| {
|
||||
matches!(
|
||||
field.metadata().kind(),
|
||||
EntryFieldKind::Username | EntryFieldKind::Email
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let ([password], [username]) = (passwords.as_slice(), usernames.as_slice()) else {
|
||||
return Err("This entry must contain exactly one username or email and one password.");
|
||||
};
|
||||
for field in [password, username] {
|
||||
let value =
|
||||
std::str::from_utf8(field.value()).map_err(|_| "The credential is not UTF-8 text.")?;
|
||||
if value.is_empty() || value.len() > 16_384 || value.chars().any(char::is_control) {
|
||||
return Err(
|
||||
"The credential field is empty, too large, or contains control characters.",
|
||||
);
|
||||
}
|
||||
}
|
||||
if !allowed_usernames.is_empty()
|
||||
&& !allowed_usernames
|
||||
.iter()
|
||||
.any(|name| name.as_bytes() == username.value())
|
||||
{
|
||||
return Err("The client requested a different account.");
|
||||
}
|
||||
Ok(CredentialValue {
|
||||
username: SecretBytes::new(username.value().to_vec()),
|
||||
password: SecretBytes::new(password.value().to_vec()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Preserve all unrelated pass fields when explicitly updating a credential.
|
||||
pub fn set_credential(
|
||||
document: &mut EntryDocument,
|
||||
username: SecretBytes,
|
||||
password: SecretBytes,
|
||||
) -> Result<(), &'static str> {
|
||||
use crate::document::EntryFieldDraft;
|
||||
for value in [&username, &password] {
|
||||
let text =
|
||||
std::str::from_utf8(value.expose()).map_err(|_| "The credential is not UTF-8 text.")?;
|
||||
if text.is_empty() || text.len() > 16_384 || text.chars().any(char::is_control) {
|
||||
return Err(
|
||||
"The credential field is empty, too large, or contains control characters.",
|
||||
);
|
||||
}
|
||||
}
|
||||
let passwords: Vec<_> = document
|
||||
.fields()
|
||||
.iter()
|
||||
.filter(|field| field.metadata().kind() == EntryFieldKind::Password)
|
||||
.map(|field| field.id())
|
||||
.collect();
|
||||
let users: Vec<_> = document
|
||||
.fields()
|
||||
.iter()
|
||||
.filter(|field| {
|
||||
matches!(
|
||||
field.metadata().kind(),
|
||||
EntryFieldKind::Username | EntryFieldKind::Email
|
||||
)
|
||||
})
|
||||
.map(|field| {
|
||||
(
|
||||
field.id(),
|
||||
field.metadata().name().unwrap_or("username").to_owned(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
if passwords.len() != 1 || users.len() > 1 {
|
||||
return Err("This entry has ambiguous credential fields; edit it in IronStorage first.");
|
||||
}
|
||||
document
|
||||
.update(
|
||||
passwords[0],
|
||||
EntryFieldDraft::line(password.expose().to_vec())
|
||||
.map_err(|_| "Invalid password field.")?,
|
||||
)
|
||||
.map_err(|_| "Could not update the password.")?;
|
||||
let (id, name) = users
|
||||
.first()
|
||||
.map(|(id, name)| (Some(*id), name.as_str()))
|
||||
.unwrap_or((None, "username"));
|
||||
let replacement = EntryFieldDraft::field(name, username.expose().to_vec())
|
||||
.map_err(|_| "Invalid username field.")?;
|
||||
if let Some(id) = id {
|
||||
document
|
||||
.update(id, replacement)
|
||||
.map_err(|_| "Could not update the username.")?;
|
||||
} else {
|
||||
document
|
||||
.add(document.fields().len(), replacement)
|
||||
.map_err(|_| "Could not add the username.")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn caller() -> CredentialCaller {
|
||||
CredentialCaller {
|
||||
package: "example.client".into(),
|
||||
certificates: vec!["AA".repeat(32)],
|
||||
origin: None,
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn untrusted_origins_stay_app_scoped_and_signature_changes_fail() {
|
||||
let mut client = caller();
|
||||
client.origin = Some("https://accounts.example/".into());
|
||||
let identity = caller_identity(&client).unwrap();
|
||||
assert_eq!(identity.title, "example.client");
|
||||
let mut requests = CredentialRequests::default();
|
||||
let request = requests
|
||||
.begin(
|
||||
&client,
|
||||
"r1".into(),
|
||||
vec!["example.client/user".into()],
|
||||
Duration::ZERO,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(request.candidates[0].matched);
|
||||
client.certificates = vec!["BB".repeat(32)];
|
||||
assert!(
|
||||
requests
|
||||
.validate(&request.token, &client, "r1", Duration::ZERO)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn pinned_browser_origin_and_hostile_origins() {
|
||||
let mut client = caller();
|
||||
client.package = "com.android.chrome".into();
|
||||
client.certificates = vec![CHROME_RELEASE.into()];
|
||||
client.origin = Some("https://EXAMPLE.com:443/".into());
|
||||
assert_eq!(
|
||||
caller_identity(&client).unwrap().title,
|
||||
"https://example.com"
|
||||
);
|
||||
for origin in [
|
||||
"http://example.com/",
|
||||
"https://user@example.com/",
|
||||
"https://example.com/login",
|
||||
"https://example.com/?spoof=true",
|
||||
] {
|
||||
client.origin = Some(origin.into());
|
||||
assert!(caller_identity(&client).is_err());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn cancellation_deadline_revision_and_recreation_reject_requests() {
|
||||
let client = caller();
|
||||
let mut requests = CredentialRequests::default();
|
||||
let request = requests
|
||||
.begin(&client, "r1".into(), vec![], Duration::ZERO)
|
||||
.unwrap();
|
||||
assert!(
|
||||
requests
|
||||
.validate(&request.token, &client, "r2", Duration::ZERO)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
requests
|
||||
.validate(&request.token, &client, "r1", REQUEST_LIFETIME)
|
||||
.is_err()
|
||||
);
|
||||
requests.cancel(&request.token);
|
||||
assert!(
|
||||
requests
|
||||
.validate(&request.token, &client, "r1", Duration::ZERO)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn html_and_duplicate_os_ids_use_the_same_strict_form_contract() {
|
||||
let user = CredentialField {
|
||||
index: 1,
|
||||
hints: vec![],
|
||||
html_attributes: vec![("autocomplete".into(), "username".into())],
|
||||
input_type: 0,
|
||||
focused: true,
|
||||
editable: true,
|
||||
};
|
||||
let password = CredentialField {
|
||||
index: 2,
|
||||
hints: vec![],
|
||||
html_attributes: vec![("type".into(), "password".into())],
|
||||
input_type: 0,
|
||||
focused: false,
|
||||
editable: true,
|
||||
};
|
||||
assert_eq!(
|
||||
credential_form(&[user.clone(), password.clone()])
|
||||
.unwrap()
|
||||
.username,
|
||||
1
|
||||
);
|
||||
let mut duplicate = password;
|
||||
duplicate.index = 1;
|
||||
assert!(credential_form(&[user.clone(), duplicate]).is_err());
|
||||
assert!(credential_form(&vec![user; 513]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambiguous_and_conflicting_forms_are_rejected() {
|
||||
let user = CredentialField {
|
||||
index: 1,
|
||||
hints: vec!["username".into()],
|
||||
html_attributes: vec![],
|
||||
input_type: 1,
|
||||
focused: true,
|
||||
editable: true,
|
||||
};
|
||||
let password = CredentialField {
|
||||
index: 2,
|
||||
hints: vec![],
|
||||
html_attributes: vec![],
|
||||
input_type: 0x81,
|
||||
focused: false,
|
||||
editable: true,
|
||||
};
|
||||
assert_eq!(
|
||||
credential_form(&[user.clone(), password.clone()]).unwrap(),
|
||||
CredentialForm {
|
||||
username: 1,
|
||||
password: 2
|
||||
}
|
||||
);
|
||||
let mut duplicate = password.clone();
|
||||
duplicate.index = 3;
|
||||
assert!(credential_form(&[user.clone(), password, duplicate]).is_err());
|
||||
let mut conflict = user;
|
||||
conflict.hints.push("password".into());
|
||||
assert!(credential_form(&[conflict]).is_err());
|
||||
}
|
||||
}
|
||||
@@ -448,6 +448,10 @@ impl MobileEntryDraft {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn into_document(self) -> EntryDocument {
|
||||
self.document
|
||||
}
|
||||
|
||||
pub fn document(&self) -> &EntryDocument {
|
||||
&self.document
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user