Implement Android Credential Manager and Autofill providers (#109)
Android ARM verification / rust (push) Failing after 49m12s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Failing after 21s

This commit is contained in:
hugo committed 2026-10-12 19:18:10 +02:00
1 parent 316a875d63
commit 0c8366d34f
48 files changed
+4138 -78

No files matched your search

+2
View File
@@ -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;
+609 -1
View File
@@ -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(&current) != 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};
+584
View File
@@ -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());
}
}
+4
View File
@@ -448,6 +448,10 @@ impl MobileEntryDraft {
Ok(())
}
pub(crate) fn into_document(self) -> EntryDocument {
self.document
}
pub fn document(&self) -> &EntryDocument {
&self.document
}