Implement Android TOTP and confirmed HOTP presentation (#107)
Android ARM verification / rust (push) Failing after 49m4s
Android ARM verification / android (push) Failing after 9s
Dependency security audit / rustsec (push) Successful in 21s

This commit is contained in:
hugo committed 2026-10-12 11:43:23 +02:00
1 parent d466b170ab
commit 6a864a8d7f
26 files changed
+1938 -94

No files matched your search

+445 -2
View File
@@ -30,13 +30,14 @@ use crate::{
MobileMutationRequest, MobileMutationService,
},
mobile_totp::{
MobileTotpDetail, MobileTotpError, MobileTotpOperation, MobileTotpPage, MobileTotpService,
MobileOtpCodePresentation, MobileTotpDetail, MobileTotpError, MobileTotpOperation,
MobileTotpPage, MobileTotpService, otp_code_presentation,
},
mobile_watch::{
MobileWatchSnapshotState, MobileWatchSnapshotStatus, WatchSnapshotError,
WatchSnapshotSender, WatchSnapshotTransfer,
},
otp::OtpError,
otp::{OtpError, OtpKind, OtpService},
recipient::RecipientPolicyManager,
repository::{
DirectoryPath, EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes,
@@ -1124,6 +1125,121 @@ impl MobileAuthentication {
})
}
pub fn presented_otp_code(
&self,
path: &str,
revision: &str,
unix_seconds: u64,
consumed_counter: Option<u64>,
) -> Result<Option<MobileOtpCodePresentation>, MobileAuthenticationError> {
self.ensure_repository_idle()?;
let document = self.open_presented_document(path, revision)?;
match otp_code_presentation(&document, unix_seconds, consumed_counter) {
Err(OtpError::ConcurrentModification { .. }) => {
self.manual_lock()?;
Err(MobileAuthenticationError::new(
MobileAuthenticationErrorKind::Conflict,
"HOTP Entry Changed",
"Refresh and unlock the entry before using its code.",
))
}
result => result.map_err(|error| totp_error(error.into())),
}
}
pub fn copy_presented_otp_code(
&self,
path: &str,
revision: &str,
unix_seconds: u64,
consumed_counter: Option<u64>,
) -> Result<MobileEntryCopy, MobileAuthenticationError> {
let code = self
.presented_otp_code(path, revision, unix_seconds, consumed_counter)?
.and_then(|value| value.code)
.ok_or_else(|| {
entry_detail(
"OTP Code Is Unavailable",
"Open a TOTP code or explicitly generate a HOTP code before copying.",
)
})?;
Ok(MobileEntryCopy {
value: String::from_utf8(code.expose().to_vec())
.map_err(|_| entry_detail("OTP Code Is Unavailable", "The code is not UTF-8."))?,
timeout_seconds: self.config.clipboard_timeout().duration().as_secs(),
})
}
pub fn advance_presented_hotp(
&self,
path: &str,
revision: &str,
confirmed: bool,
) -> Result<MobileOtpCodePresentation, MobileAuthenticationError> {
if !confirmed {
return Err(MobileAuthenticationError::new(
MobileAuthenticationErrorKind::Cancelled,
"HOTP Generation Cancelled",
"The HOTP counter was not changed.",
));
}
self.ensure_repository_idle()?;
self.ensure_active()?;
self.reserve_entry_mutation()?;
let result = (|| {
let document = self.open_presented_document(path, revision)?;
if self.editor_state(path)?.0 {
return Err(entry_detail(
"HOTP Entry Is Being Edited",
"Close the entry editor before generating a code.",
));
}
if document
.otp_uri()
.map_err(|error| totp_error(error.into()))?
.is_none_or(|uri| uri.kind() != OtpKind::Hotp)
{
return Err(totp_error(OtpError::NotHotp.into()));
}
let (handle, key, identity) = {
let status = self.status()?;
let active = status.active.as_ref().ok_or_else(locked_error)?;
(
active.handle.clone(),
active.key.clone(),
status.git_identity.clone(),
)
};
let mut provider = KeyOnlyProvider::new(handle, &key);
let mut committer =
AutomaticEntryCommitter::for_entry(&self.repository, path, identity)
.map_err(|error| entry_detail("HOTP Counter Could Not Be Updated", error))?;
let outcome = OtpService::new(&self.repository, &self.keys)
.code_for_document(&document, &mut provider, &mut committer)
.map_err(|error| totp_error(error.into()))?;
let committed_error = || {
entry_detail(
"HOTP Counter Was Updated",
"Refresh and unlock the entry before generating another code. The previous counter was already consumed.",
)
};
let updated = self
.open_active_document(path)
.map_err(|_| committed_error())?;
let code = otp_code_presentation(&updated, 0, outcome.counter())
.map_err(|_| committed_error())?
.ok_or_else(committed_error)?;
if code.code.as_ref().map(SecretBytes::expose) != Some(outcome.code().expose()) {
self.manual_lock()?;
return Err(committed_error());
}
self.reconcile_saved_document(&updated);
Ok(code)
})();
self.release_entry_mutation();
result
}
pub fn prepare_presented_entry_mutation(
&self,
path: &str,
@@ -1283,6 +1399,29 @@ impl MobileAuthentication {
self.search_cached_totp_page("")
}
pub fn cached_totp_navigation(
&self,
) -> Result<
Option<Vec<crate::mobile_passwords::navigation::NavigationRow>>,
MobileAuthenticationError,
> {
self.cached_totp_page()?
.map(|page| {
page.rows()
.iter()
.map(|row| {
let path = EntryPath::parse(row.path()).map_err(entry_error)?;
crate::mobile_passwords::navigation::entry_navigation_row(
&self.repository,
&path,
)
.map_err(|error| entry_detail(error.title(), error.detail()))
})
.collect()
})
.transpose()
}
pub fn search_cached_totp_page(
&self,
query: &str,
@@ -2452,6 +2591,310 @@ mod tests {
watch_preference,
};
struct OtpClock(std::sync::atomic::AtomicU64);
impl crate::authentication::AuthenticationClock for OtpClock {
fn now(&self) -> std::time::Duration {
std::time::Duration::from_secs(self.0.load(std::sync::atomic::Ordering::Relaxed))
}
}
fn otp_fixture(
uri: &str,
) -> (
tempfile::TempDir,
std::sync::Arc<super::MobileAuthentication>,
std::sync::Arc<OtpClock>,
) {
use crate::{
crypto::KeyStore,
git::{GitIdentity, GitRepository},
recipient::RecipientPolicyManager,
repository::{EntryPath, Repository, SecretBytes},
};
let (root, private, backend) = crate::mobile_onboarding::android::tests::home_fixture();
let config = private.configuration().unwrap().unwrap();
let repository = Repository::open(config.vault()).unwrap();
let keys = KeyStore::load(config.key_material()).unwrap();
let path = EntryPath::parse("otp").unwrap();
let policy = RecipientPolicyManager::new(&repository, &keys)
.resolve_for_entry(&path, None)
.unwrap();
let encrypted = keys
.encrypt(
SecretBytes::new(format!("fixture-password\n{uri}\n").into_bytes()),
policy.recipients(),
)
.unwrap();
repository.write_entry(&path, &encrypted).unwrap();
GitRepository::open(&repository, GitIdentity::ironstorage())
.unwrap()
.commit_all("Seed encrypted OTP fixture")
.unwrap();
let clock = std::sync::Arc::new(OtpClock(std::sync::atomic::AtomicU64::new(0)));
let authentication = private.authentication(backend, clock.clone()).unwrap();
(root, authentication, clock)
}
#[test]
fn otp_presentation_matches_rfc_vectors_and_boundaries_without_renewing_the_lease() {
use crate::{otp::OtpCodeValidity, repository::SecretBytes};
for (algorithm, secret, expected) in [
("SHA1", "12345678901234567890", "94287082"),
("SHA256", "12345678901234567890123456789012", "46119246"),
(
"SHA512",
"1234567890123456789012345678901234567890123456789012345678901234",
"90693936",
),
] {
let secret = data_encoding::BASE32_NOPAD.encode(secret.as_bytes());
let uri = format!(
"otpauth://totp/RFC:alice?secret={secret}&algorithm={algorithm}&digits=8&period=30&issuer=RFC"
);
let (_root, authentication, clock) = otp_fixture(&uri);
assert!(authentication.cached_totp_navigation().unwrap().is_none());
authentication
.unlock_entry(
"otp",
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
)
.unwrap();
authentication.set_authentication_timeout_text("2").unwrap();
let page = authentication.entry_page("otp", 59).unwrap();
let revision = page.page().revision();
let code = authentication
.presented_otp_code("otp", revision, 59, None)
.unwrap()
.unwrap();
assert_eq!(code.code.as_ref().unwrap().expose(), expected.as_bytes());
assert_eq!(
code.grouped_code.as_ref().unwrap().expose(),
format!("{} {}", &expected[..4], &expected[4..]).as_bytes()
);
assert_eq!(code.remaining_seconds, Some(1));
assert_eq!(code.valid_until, Some(60));
let rows = authentication.cached_totp_navigation().unwrap().unwrap();
assert_eq!(rows.len(), 1);
let cached = std::fs::read(
authentication
.config
.source()
.with_file_name("totp-catalog.toml"),
)
.unwrap();
let at_boundary = authentication
.presented_otp_code("otp", revision, 60, None)
.unwrap()
.unwrap();
assert_ne!(
at_boundary.code.as_ref().unwrap().expose(),
expected.as_bytes()
);
assert_eq!(at_boundary.remaining_seconds, Some(30));
assert_eq!(at_boundary.valid_until, Some(90));
assert_eq!(
std::fs::read(
authentication
.config
.source()
.with_file_name("totp-catalog.toml")
)
.unwrap(),
cached
);
let window = OtpCodeValidity::Timed {
valid_until: 60,
period: 30,
};
assert!(!window.is_current(29));
assert!(window.is_current(30));
assert!(window.is_current(59));
assert!(!window.is_current(60));
clock.0.store(1, std::sync::atomic::Ordering::Relaxed);
authentication
.presented_otp_code("otp", revision, 61, None)
.unwrap();
assert_eq!(authentication.state().unwrap().remaining_seconds(), 1);
clock.0.store(2, std::sync::atomic::Ordering::Relaxed);
assert!(
authentication
.presented_otp_code("otp", revision, 62, None)
.is_err()
);
assert!(!authentication.state().unwrap().unlocked());
}
}
#[test]
fn malformed_otp_keeps_the_structured_entry_readable() {
use crate::repository::SecretBytes;
let (_root, authentication, _) = otp_fixture("otpauth://totp/Invalid?secret=!");
authentication
.unlock_entry(
"otp",
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
)
.unwrap();
let page = authentication.entry_page("otp", 59).unwrap();
assert!(!page.page().sections().is_empty());
assert!(
authentication
.presented_otp_code("otp", page.page().revision(), 59, None)
.unwrap()
.is_none()
);
}
#[test]
fn hotp_requires_confirmation_commits_once_and_copy_refresh_never_advance() {
use crate::repository::SecretBytes;
let (_root, authentication, _) = otp_fixture(
"otpauth://hotp/RFC:alice?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&counter=0&issuer=RFC",
);
authentication
.unlock_entry(
"otp",
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
)
.unwrap();
let page = authentication.entry_page("otp", 0).unwrap();
let revision = page.page().revision();
let before = authentication
.repository
.read_entry(&crate::repository::EntryPath::parse("otp").unwrap())
.unwrap();
assert!(
authentication
.advance_presented_hotp("otp", revision, false)
.is_err()
);
assert_eq!(
authentication
.repository
.read_entry(&crate::repository::EntryPath::parse("otp").unwrap())
.unwrap(),
before
);
assert!(
authentication
.presented_otp_code("otp", revision, 0, None)
.unwrap()
.unwrap()
.code
.is_none()
);
assert!(
authentication
.copy_presented_otp_code("otp", revision, 0, None)
.is_err()
);
let issued = authentication
.advance_presented_hotp("otp", revision, true)
.unwrap();
assert_eq!(issued.code.as_ref().unwrap().expose(), b"287082");
assert_eq!(issued.counter, Some(1));
assert_eq!(issued.next_counter, Some(2));
assert_eq!(
authentication
.copy_presented_otp_code("otp", &issued.document_revision, 100, issued.counter)
.unwrap()
.value(),
"287082"
);
let refreshed = authentication
.presented_otp_code("otp", &issued.document_revision, 200, issued.counter)
.unwrap()
.unwrap();
assert_eq!(refreshed.next_counter, Some(2));
let next = authentication
.advance_presented_hotp("otp", &issued.document_revision, true)
.unwrap();
assert_eq!(next.code.as_ref().unwrap().expose(), b"359152");
assert_eq!(next.next_counter, Some(3));
assert!(
authentication
.copy_presented_otp_code("otp", &next.document_revision, 201, issued.counter)
.is_err()
);
authentication.manual_lock().unwrap();
assert!(
authentication
.copy_presented_otp_code("otp", &next.document_revision, 202, next.counter)
.is_err()
);
}
#[test]
fn hotp_commit_failure_and_overflow_leave_the_ciphertext_unchanged() {
use crate::repository::{EntryPath, SecretBytes};
for counter in ["0", "18446744073709551615"] {
let (_root, authentication, _) = otp_fixture(&format!(
"otpauth://hotp/RFC:alice?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&counter={counter}"
));
authentication
.unlock_entry(
"otp",
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
)
.unwrap();
let path = EntryPath::parse("otp").unwrap();
let before = authentication.repository.read_entry(&path).unwrap();
let page = authentication.entry_page("otp", 0).unwrap();
if counter == "0" {
std::fs::write(
authentication
.repository
.root_path()
.join(".git/index.lock"),
b"locked",
)
.unwrap();
}
assert!(
authentication
.advance_presented_hotp("otp", page.page().revision(), true)
.is_err()
);
assert_eq!(authentication.repository.read_entry(&path).unwrap(), before);
}
}
#[test]
fn custom_period_and_stale_revision_are_storage_owned() {
use crate::repository::SecretBytes;
let (_root, authentication, _) = otp_fixture(
"otpauth://totp/Custom:alice?secret=JBSWY3DPEHPK3PXP&algorithm=SHA256&digits=6&period=60&issuer=Custom",
);
authentication
.unlock_entry(
"otp",
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
)
.unwrap();
let page = authentication.entry_page("otp", 59).unwrap();
let code = authentication
.presented_otp_code("otp", page.page().revision(), 59, None)
.unwrap()
.unwrap();
assert_eq!(code.period, Some(60));
assert_eq!(code.valid_until, Some(60));
assert_eq!(code.remaining_seconds, Some(1));
assert_eq!(code.code.as_ref().unwrap().expose().len(), 6);
assert_eq!(code.grouped_code.as_ref().unwrap().expose().len(), 7);
let next = authentication
.presented_otp_code("otp", page.page().revision(), 60, None)
.unwrap()
.unwrap();
assert_eq!(next.valid_until, Some(120));
assert_eq!(next.remaining_seconds, Some(60));
assert!(
authentication
.presented_otp_code("otp", "stale", 60, None)
.is_err()
);
assert!(!authentication.state().unwrap().unlocked());
}
#[test]
fn explicit_biometric_enrollment_and_restore_work_without_a_password_entry() {
use crate::authentication::SystemAuthenticationClock;
@@ -5,8 +5,22 @@ use super::{
};
use crate::{
mobile_private_storage::{PrivateStorage, exclusive_lock},
repository::{DirectoryPath, Repository},
repository::{DirectoryPath, EntryPath, Repository},
};
pub(crate) fn entry_navigation_row(
repository: &Repository,
path: &EntryPath,
) -> Result<NavigationRow, MobilePasswordError> {
let row = super::search_row(path, &path.to_string());
let revision = repository
.navigation_revision(path.as_path(), true)
.map_err(MobilePasswordError::repository)?;
Ok(NavigationRow {
row,
revision: HEXLOWER.encode(&Sha256::digest(revision.as_bytes())),
})
}
use cap_std::{ambient_authority, fs::Dir};
use data_encoding::HEXLOWER;
use sha2::{Digest, Sha256};
+84
View File
@@ -204,6 +204,90 @@ pub struct MobileTotpDetail {
watch: MobileWatchSnapshotStatus,
}
pub struct MobileOtpCodePresentation {
pub document_revision: String,
pub kind: OtpKind,
pub issuer: Option<String>,
pub account: String,
pub code: Option<SecretBytes>,
pub grouped_code: Option<SecretBytes>,
pub period: Option<u64>,
pub valid_until: Option<u64>,
pub remaining_seconds: Option<u64>,
pub counter: Option<u64>,
pub next_counter: Option<u64>,
}
impl fmt::Debug for MobileOtpCodePresentation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("MobileOtpCodePresentation([REDACTED])")
}
}
pub(crate) fn otp_code_presentation(
document: &EntryDocument,
unix_seconds: u64,
consumed_counter: Option<u64>,
) -> Result<Option<MobileOtpCodePresentation>, OtpError> {
let Some(uri) = document.otp_uri().ok().flatten() else {
return Ok(None);
};
let (code, remaining_seconds, counter, valid_until) = match uri.kind() {
OtpKind::Totp => {
if consumed_counter.is_some() {
return Err(OtpError::UnexpectedCounter);
}
let period = uri.period().ok_or(OtpError::NotTotp)?;
let end = (unix_seconds / period)
.checked_add(1)
.and_then(|step| step.checked_mul(period))
.ok_or(OtpError::CounterOverflow)?;
(
Some(uri.code_at(unix_seconds)?),
Some(end - unix_seconds),
None,
Some(end),
)
}
OtpKind::Hotp => {
let next = uri.counter().ok_or(OtpError::MissingCounter)?;
let code = if let Some(consumed) = consumed_counter {
if consumed != next {
return Err(OtpError::ConcurrentModification {
entry: document.path().clone(),
});
}
Some(uri.code_for_counter(consumed)?)
} else {
None
};
(code, None, consumed_counter, None)
}
};
let grouped_code = code.as_ref().map(|code| {
let bytes = code.expose();
let middle = bytes.len() / 2;
let mut grouped = Vec::with_capacity(bytes.len() + 1);
grouped.extend_from_slice(&bytes[..middle]);
grouped.push(b' ');
grouped.extend_from_slice(&bytes[middle..]);
SecretBytes::new(grouped)
});
Ok(Some(MobileOtpCodePresentation {
document_revision: crate::mobile_entry::document_revision(document),
kind: uri.kind(),
issuer: uri.issuer().map(str::to_owned),
account: uri.account().to_owned(),
code,
grouped_code,
period: uri.period(),
valid_until,
remaining_seconds,
counter,
next_counter: uri.counter().and_then(|counter| counter.checked_add(1)),
}))
}
impl MobileTotpDetail {
pub fn path(&self) -> &str {
&self.path
+32
View File
@@ -426,6 +426,21 @@ pub enum OtpCodeValidity {
}
impl OtpCodeValidity {
pub fn is_current(self, unix_seconds: u64) -> bool {
match self {
Self::Timed {
valid_until,
period,
} => {
period > 0
&& valid_until
.checked_sub(period)
.is_some_and(|start| start <= unix_seconds && unix_seconds < valid_until)
}
Self::CounterBased { .. } => true,
}
}
pub fn valid_until(self) -> Option<u64> {
match self {
Self::Timed { valid_until, .. } => Some(valid_until),
@@ -678,6 +693,23 @@ impl<'a> OtpService<'a> {
)
}
pub(crate) fn code_for_document(
&self,
document: &crate::document::EntryDocument,
provider: &mut impl SecretProvider,
committer: &mut impl EntryCommitter,
) -> Result<OtpCodeOutcome, OtpError> {
let (path, original, plaintext, range, uri) =
self.load_code_entry(&document.path().to_string(), provider)?;
if document.original_ciphertext() != Some(&original) {
return Err(OtpError::ConcurrentModification { entry: path });
}
if uri.kind() != OtpKind::Hotp {
return Err(OtpError::NotHotp);
}
self.finish_code(path, original, plaintext, range, uri, 0, None, committer)
}
/// Generate a code with storage-owned lazy Git selection. TOTP is
/// read-only and never opens Git; HOTP opens the innermost repository only
/// after the token has been decrypted and identified as counter based.