Implement Android TOTP and confirmed HOTP presentation (#107)
This commit is contained in:
1 parent
d466b170ab
commit
6a864a8d7f
26 files changed
+1938
-94
No files matched your search
@@ -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};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user