From 2026637a0efa5374064c52f047323b3a7b07042f Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Tue, 11 Aug 2026 23:40:24 +0200 Subject: [PATCH] Show TOTP codes in password details (#75) --- apple/Generated/ironstorage_apple.swift | 93 +++++- apple/Generated/ironstorage_appleFFI.h | 2 +- apple/Sources/App/IronStorageApp.swift | 336 ++++++++++++++++---- crates/apple/src/lib.rs | 25 +- crates/storage/src/document.rs | 13 +- crates/storage/src/mobile_authentication.rs | 72 ++++- crates/storage/src/mobile_totp.rs | 140 ++++++-- crates/storage/src/otp.rs | 2 +- crates/storage/tests/mobile_totp.rs | 67 ++++ 9 files changed, 640 insertions(+), 110 deletions(-) diff --git a/apple/Generated/ironstorage_apple.swift b/apple/Generated/ironstorage_apple.swift index 0a33396..5efae92 100644 --- a/apple/Generated/ironstorage_apple.swift +++ b/apple/Generated/ironstorage_apple.swift @@ -621,7 +621,7 @@ public protocol MobileAuthenticationProtocol: AnyObject, Sendable { func entryEditor(editor: UInt64) throws -> MobileEntryEditorPage - func entryPage(path: String) throws -> MobileEntryPage + func entryPage(path: String, unixSeconds: UInt64) throws -> MobileEntryPresentation func generateEntryEditorPassword(editor: UInt64, length: UInt32?, noSymbols: Bool) throws -> MobileEntryEditorPage @@ -806,12 +806,13 @@ open func entryEditor(editor: UInt64)throws -> MobileEntryEditorPage { }) } -open func entryPage(path: String)throws -> MobileEntryPage { - return try FfiConverterTypeMobileEntryPage_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { +open func entryPage(path: String, unixSeconds: UInt64)throws -> MobileEntryPresentation { + return try FfiConverterTypeMobileEntryPresentation_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { uniffiCallStatus in uniffi_ironstorage_apple_fn_method_mobileauthentication_entry_page( self.uniffiCloneHandle(), - FfiConverterString.lower(path),uniffiCallStatus + FfiConverterString.lower(path), + FfiConverterUInt64.lower(unixSeconds),uniffiCallStatus ) }) } @@ -2320,6 +2321,64 @@ public func FfiConverterTypeMobileEntryPage_lower(_ value: MobileEntryPage) -> R } +public struct MobileEntryPresentation: Equatable, Hashable { + public var page: MobileEntryPage + public var totp: MobileTotpDetail? + public var cacheNotice: String? + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(page: MobileEntryPage, totp: MobileTotpDetail?, cacheNotice: String?) { + self.page = page + self.totp = totp + self.cacheNotice = cacheNotice + } + + + + +} + +#if compiler(>=6) +extension MobileEntryPresentation: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileEntryPresentation: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileEntryPresentation { + return + try MobileEntryPresentation( + page: FfiConverterTypeMobileEntryPage.read(from: &buf), + totp: FfiConverterOptionTypeMobileTotpDetail.read(from: &buf), + cacheNotice: FfiConverterOptionString.read(from: &buf) + ) + } + + public static func write(_ value: MobileEntryPresentation, into buf: inout [UInt8]) { + FfiConverterTypeMobileEntryPage.write(value.page, into: &buf) + FfiConverterOptionTypeMobileTotpDetail.write(value.totp, into: &buf) + FfiConverterOptionString.write(value.cacheNotice, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileEntryPresentation_lift(_ buf: RustBuffer) throws -> MobileEntryPresentation { + return try FfiConverterTypeMobileEntryPresentation.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileEntryPresentation_lower(_ value: MobileEntryPresentation) -> RustBuffer { + return FfiConverterTypeMobileEntryPresentation.lower(value) +} + + public struct MobileEntrySection: Equatable, Hashable { public var kind: MobileEntrySectionKind public var title: String @@ -6231,6 +6290,30 @@ fileprivate struct FfiConverterOptionTypeMobileKeyTransferKey: FfiConverterRustB } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionTypeMobileTotpDetail: FfiConverterRustBuffer { + typealias SwiftType = MobileTotpDetail? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterTypeMobileTotpDetail.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterTypeMobileTotpDetail.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -6775,7 +6858,7 @@ private let initializationResult: InitializationResult = { if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_entry_editor() != 65179) { return InitializationResult.apiChecksumMismatch } - if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_entry_page() != 56594) { + if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_entry_page() != 9073) { return InitializationResult.apiChecksumMismatch } if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_generate_entry_editor_password() != 34289) { diff --git a/apple/Generated/ironstorage_appleFFI.h b/apple/Generated/ironstorage_appleFFI.h index 7c93f8d..a1ac93b 100644 --- a/apple/Generated/ironstorage_appleFFI.h +++ b/apple/Generated/ironstorage_appleFFI.h @@ -300,7 +300,7 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_entry_editor( #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_ENTRY_PAGE #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_ENTRY_PAGE -RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_entry_page(uint64_t ptr, RustBuffer path, RustCallStatus *_Nonnull out_status +RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_entry_page(uint64_t ptr, RustBuffer path, uint64_t unix_seconds, RustCallStatus *_Nonnull out_status ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_GENERATE_ENTRY_EDITOR_PASSWORD diff --git a/apple/Sources/App/IronStorageApp.swift b/apple/Sources/App/IronStorageApp.swift index 6295643..6d4481d 100644 --- a/apple/Sources/App/IronStorageApp.swift +++ b/apple/Sources/App/IronStorageApp.swift @@ -2150,15 +2150,111 @@ private final class TotpListViewController: UITableViewController, MobileTabRoot } @MainActor -private final class TotpDetailViewController: UITableViewController { - private let authentication: MobileAuthentication - private var detail: MobileTotpDetail - private let codeContainer = UIView() +private final class TotpCodeView: UIView { private let issuerLabel = UILabel() private let accountLabel = UILabel() private let codeLabel = UILabel() private let countdownLabel = UILabel() private let progress = UIProgressView(progressViewStyle: .default) + var copyRequested: (() -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + issuerLabel.font = .preferredFont(forTextStyle: .title2) + issuerLabel.adjustsFontForContentSizeCategory = true + issuerLabel.textAlignment = .center + accountLabel.font = .preferredFont(forTextStyle: .body) + accountLabel.textColor = .secondaryLabel + accountLabel.adjustsFontForContentSizeCategory = true + accountLabel.textAlignment = .center + accountLabel.numberOfLines = 0 + codeLabel.font = UIFontMetrics(forTextStyle: .largeTitle).scaledFont( + for: .monospacedDigitSystemFont(ofSize: 48, weight: .semibold) + ) + codeLabel.adjustsFontForContentSizeCategory = true + codeLabel.textAlignment = .center + codeLabel.minimumScaleFactor = 0.55 + codeLabel.adjustsFontSizeToFitWidth = true + codeLabel.layer.cornerRadius = 12 + codeLabel.layer.masksToBounds = true + codeLabel.isAccessibilityElement = true + codeLabel.isUserInteractionEnabled = true + codeLabel.accessibilityTraits.insert(.button) + codeLabel.accessibilityHint = "Copies the current code" + countdownLabel.font = .preferredFont(forTextStyle: .footnote) + countdownLabel.textColor = .secondaryLabel + countdownLabel.adjustsFontForContentSizeCategory = true + countdownLabel.textAlignment = .center + + let stack = UIStackView( + arrangedSubviews: [issuerLabel, accountLabel, codeLabel, progress, countdownLabel] + ) + stack.axis = .vertical + stack.spacing = 12 + stack.translatesAutoresizingMaskIntoConstraints = false + addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + stack.topAnchor.constraint(equalTo: topAnchor, constant: 20), + stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -20), + ]) + codeLabel.addGestureRecognizer( + UITapGestureRecognizer(target: self, action: #selector(copyCode)) + ) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } + + func apply(_ detail: MobileTotpDetail) { + issuerLabel.text = detail.issuer ?? "TOTP" + accountLabel.text = detail.account + codeLabel.text = groupedCode(detail.code) + codeLabel.accessibilityLabel = + "Current code \(detail.code.map(String.init).joined(separator: " "))" + updateCountdown(detail) + } + + func updateCountdown(_ detail: MobileTotpDetail) { + let remaining = detail.validUntil.saturatingSubtracting(currentUnixSeconds()) + let fraction = detail.period == 0 ? 0 : Float(remaining) / Float(detail.period) + progress.setProgress(min(max(fraction, 0), 1), animated: true) + countdownLabel.text = "\(remaining) seconds remaining" + progress.accessibilityLabel = "Code validity" + progress.accessibilityValue = countdownLabel.text + } + + func clear() { + issuerLabel.text = nil + accountLabel.text = nil + codeLabel.text = nil + countdownLabel.text = nil + progress.progress = 0 + } + + func flashCopied() { + UINotificationFeedbackGenerator().notificationOccurred(.success) + UIAccessibility.post(notification: .announcement, argument: "TOTP code copied") + UIView.animate(withDuration: 0.12, animations: { + self.codeLabel.backgroundColor = .systemGreen.withAlphaComponent(0.28) + }) { _ in + UIView.animate(withDuration: 0.55) { self.codeLabel.backgroundColor = .clear } + } + } + + @objc private func copyCode() { + copyRequested?() + } +} + +@MainActor +private final class TotpDetailViewController: UITableViewController { + private let authentication: MobileAuthentication + private var detail: MobileTotpDetail + private let codeView = TotpCodeView() private let watchSwitch = UISwitch() private var timerTask: Task? private var loadTask: Task? @@ -2354,56 +2450,15 @@ private final class TotpDetailViewController: UITableViewController { } private func configureHeader() { - issuerLabel.font = .preferredFont(forTextStyle: .title2) - issuerLabel.adjustsFontForContentSizeCategory = true - issuerLabel.textAlignment = .center - accountLabel.font = .preferredFont(forTextStyle: .body) - accountLabel.textColor = .secondaryLabel - accountLabel.adjustsFontForContentSizeCategory = true - accountLabel.textAlignment = .center - accountLabel.numberOfLines = 0 - codeLabel.font = UIFontMetrics(forTextStyle: .largeTitle).scaledFont( - for: .monospacedDigitSystemFont(ofSize: 48, weight: .semibold) - ) - codeLabel.adjustsFontForContentSizeCategory = true - codeLabel.textAlignment = .center - codeLabel.minimumScaleFactor = 0.55 - codeLabel.adjustsFontSizeToFitWidth = true - codeLabel.layer.cornerRadius = 12 - codeLabel.layer.masksToBounds = true - codeLabel.isAccessibilityElement = true - codeLabel.isUserInteractionEnabled = true - codeLabel.accessibilityTraits.insert(.button) - codeLabel.accessibilityHint = "Copies the current code" - countdownLabel.font = .preferredFont(forTextStyle: .footnote) - countdownLabel.textColor = .secondaryLabel - countdownLabel.adjustsFontForContentSizeCategory = true - countdownLabel.textAlignment = .center - let stack = UIStackView(arrangedSubviews: [issuerLabel, accountLabel, codeLabel, progress, countdownLabel]) - stack.axis = .vertical - stack.spacing = 12 - stack.translatesAutoresizingMaskIntoConstraints = false - codeContainer.addSubview(stack) - NSLayoutConstraint.activate([ - stack.leadingAnchor.constraint(equalTo: codeContainer.layoutMarginsGuide.leadingAnchor), - stack.trailingAnchor.constraint(equalTo: codeContainer.layoutMarginsGuide.trailingAnchor), - stack.topAnchor.constraint(equalTo: codeContainer.topAnchor, constant: 20), - stack.bottomAnchor.constraint(equalTo: codeContainer.bottomAnchor, constant: -20), - ]) - codeContainer.frame = CGRect(x: 0, y: 0, width: tableView.bounds.width, height: 1) - tableView.tableHeaderView = codeContainer - codeLabel.addGestureRecognizer( - UITapGestureRecognizer(target: self, action: #selector(copyRequested)) - ) + codeView.frame = CGRect(x: 0, y: 0, width: tableView.bounds.width, height: 1) + codeView.copyRequested = { [weak self] in self?.copyRequested() } + tableView.tableHeaderView = codeView } private func apply(_ detail: MobileTotpDetail) { self.detail = detail title = detail.issuer ?? detail.account - issuerLabel.text = detail.issuer ?? "TOTP" - accountLabel.text = detail.account - codeLabel.text = groupedCode(detail.code) - codeLabel.accessibilityLabel = "Current code \(detail.code.map(String.init).joined(separator: " "))" + codeView.apply(detail) watchSwitch.setOn(detail.sharedWithWatch, animated: false) tableView.reloadData() updateCountdown() @@ -2429,12 +2484,7 @@ private final class TotpDetailViewController: UITableViewController { } private func updateCountdown() { - let remaining = detail.validUntil.saturatingSubtracting(currentUnixSeconds()) - let fraction = detail.period == 0 ? 0 : Float(remaining) / Float(detail.period) - progress.setProgress(min(max(fraction, 0), 1), animated: true) - countdownLabel.text = "\(remaining) seconds remaining" - progress.accessibilityLabel = "Code validity" - progress.accessibilityValue = countdownLabel.text + codeView.updateCountdown(detail) } private func refreshCode() { @@ -2467,13 +2517,7 @@ private final class TotpDetailViewController: UITableViewController { } private func flashCopied() { - UINotificationFeedbackGenerator().notificationOccurred(.success) - UIAccessibility.post(notification: .announcement, argument: "TOTP code copied") - UIView.animate(withDuration: 0.12, animations: { - self.codeLabel.backgroundColor = .systemGreen.withAlphaComponent(0.28) - }) { _ in - UIView.animate(withDuration: 0.55) { self.codeLabel.backgroundColor = .clear } - } + codeView.flashCopied() } private func handle(_ failure: AuthenticationFailure) { @@ -2489,7 +2533,7 @@ private final class TotpDetailViewController: UITableViewController { } else { timerTask?.cancel() detail.code = "" - codeLabel.text = nil + codeView.clear() navigationItem.rightBarButtonItem = nil watchSwitch.isEnabled = false var configuration = UIContentUnavailableConfiguration.empty() @@ -2511,7 +2555,7 @@ private final class TotpDetailViewController: UITableViewController { loadTask?.cancel() clipboardTask?.cancel() detail.code = "" - codeLabel.text = nil + codeView.clear() if UIPasteboard.general.string == copiedValue { UIPasteboard.general.items = [] } @@ -3646,12 +3690,18 @@ private struct PasswordFailure: Error, Sendable { private final class LockedPasswordViewController: UITableViewController { private let entry: MobilePasswordRow private let authentication: MobileAuthentication? + private let totpCodeView = TotpCodeView() private var unlockTask: Task? private var entryTask: Task? + private var totpTask: Task? + private var totpTimerTask: Task? private var clipboardTask: Task? private var feedbackTask: Task? private var state: MobileAuthenticationState? private var page: MobileEntryPage? + private var totp: MobileTotpDetail? + private var cacheNotice: String? + private var copiedTotpValue: String? private var revealedValues: [UInt64: String] = [:] init(entry: MobilePasswordRow, authentication: MobileAuthentication?) { @@ -3685,6 +3735,8 @@ private final class LockedPasswordViewController: UITableViewController { deinit { unlockTask?.cancel() entryTask?.cancel() + totpTask?.cancel() + totpTimerTask?.cancel() clipboardTask?.cancel() feedbackTask?.cancel() NotificationCenter.default.removeObserver(self) @@ -3695,6 +3747,19 @@ private final class LockedPasswordViewController: UITableViewController { refreshState() } + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + guard let header = tableView.tableHeaderView else { return } + let height = header.systemLayoutSizeFitting( + CGSize(width: tableView.bounds.width, height: 0), + withHorizontalFittingPriority: .required, + verticalFittingPriority: .fittingSizeLevel + ).height + guard abs(header.frame.height - height) > 0.5 else { return } + header.frame.size.height = height + tableView.tableHeaderView = header + } + override func numberOfSections(in tableView: UITableView) -> Int { page?.sections.count ?? 0 } @@ -3713,6 +3778,14 @@ private final class LockedPasswordViewController: UITableViewController { page?.sections[section].title } + override func tableView( + _ tableView: UITableView, + titleForFooterInSection section: Int + ) -> String? { + guard let page, section == page.sections.indices.last else { return nil } + return cacheNotice + } + override func tableView( _ tableView: UITableView, cellForRowAt indexPath: IndexPath @@ -3853,8 +3926,11 @@ private final class LockedPasswordViewController: UITableViewController { entryTask = Task { [weak self] in let result = await Task.detached(priority: .userInitiated) { do { - return Result.success( - try authentication.entryPage(path: path) + return Result.success( + try authentication.entryPage( + path: path, + unixSeconds: currentUnixSeconds() + ) ) } catch let error as MobileAuthenticationFfiError { return .failure(AuthenticationFailure(error)) @@ -3864,11 +3940,14 @@ private final class LockedPasswordViewController: UITableViewController { }.value guard !Task.isCancelled, let self else { return } switch result { - case let .success(page): - self.page = page - title = page.title + case let .success(presentation): + page = presentation.page + totp = presentation.totp + cacheNotice = presentation.cacheNotice + title = presentation.page.title contentUnavailableConfiguration = nil installLockButton() + configureTotpHeader() tableView.reloadData() case let .failure(failure): handle(failure) @@ -3923,6 +4002,115 @@ private final class LockedPasswordViewController: UITableViewController { } } + private func configureTotpHeader() { + guard let totp else { + totpTimerTask?.cancel() + totpCodeView.clear() + tableView.tableHeaderView = nil + return + } + totpCodeView.frame = CGRect(x: 0, y: 0, width: tableView.bounds.width, height: 1) + totpCodeView.copyRequested = { [weak self] in self?.copyTotpCode() } + totpCodeView.apply(totp) + tableView.tableHeaderView = totpCodeView + startTotpTimer() + } + + private func startTotpTimer() { + totpTimerTask?.cancel() + totpTimerTask = Task { [weak self] in + while !Task.isCancelled { + do { + try await Task.sleep(for: .seconds(1)) + } catch { + return + } + guard let self, let totp else { return } + if currentUnixSeconds() >= totp.validUntil { + refreshTotpCode() + } else { + totpCodeView.updateCountdown(totp) + } + } + } + } + + private func refreshTotpCode() { + guard totpTask == nil, let authentication else { return } + let path = entry.path + totpTask = Task { [weak self] in + let result = await Task.detached(priority: .userInitiated) { + do { + return Result.success( + try authentication.totpDetail( + path: path, + unixSeconds: currentUnixSeconds() + ) + ) + } catch let error as MobileAuthenticationFfiError { + return .failure(AuthenticationFailure(error)) + } catch { + return .failure(.unexpected) + } + }.value + guard let self else { return } + totpTask = nil + guard !Task.isCancelled else { return } + switch result { + case let .success(detail): + totp = detail + totpCodeView.apply(detail) + case let .failure(failure): + handle(failure) + } + } + } + + private func copyTotpCode() { + guard totpTask == nil, let authentication else { return } + let path = entry.path + totpTask = Task { [weak self] in + let result = await Task.detached(priority: .userInitiated) { + do { + try authentication.touchUserActivity() + return Result.success( + try authentication.copyTotpCode( + path: path, + unixSeconds: currentUnixSeconds() + ) + ) + } catch let error as MobileAuthenticationFfiError { + return .failure(AuthenticationFailure(error)) + } catch { + return .failure(.unexpected) + } + }.value + guard let self else { return } + totpTask = nil + guard !Task.isCancelled else { return } + switch result { + case let .success(copy): + UIPasteboard.general.string = copy.value + copiedTotpValue = copy.value + totpCodeView.flashCopied() + clipboardTask?.cancel() + clipboardTask = Task { @MainActor in + do { + try await Task.sleep(for: .seconds(copy.timeoutSeconds)) + } catch { + return + } + if UIPasteboard.general.string == copy.value { + UIPasteboard.general.items = [] + } + self.copiedTotpValue = nil + } + case let .failure(failure): + handle(failure) + } + } + } + private func edit(_ field: MobileEntryField) { guard let authentication else { return } entryTask?.cancel() @@ -4010,8 +4198,18 @@ private final class LockedPasswordViewController: UITableViewController { private func maskAndDiscardEntry() { entryTask?.cancel() + totpTask?.cancel() + totpTimerTask?.cancel() feedbackTask?.cancel() page = nil + totp = nil + cacheNotice = nil + totpCodeView.clear() + tableView.tableHeaderView = nil + if UIPasteboard.general.string == copiedTotpValue { + UIPasteboard.general.items = [] + } + copiedTotpValue = nil revealedValues.removeAll(keepingCapacity: false) tableView.reloadData() navigationItem.rightBarButtonItem = nil diff --git a/crates/apple/src/lib.rs b/crates/apple/src/lib.rs index f258b87..d4e5941 100644 --- a/crates/apple/src/lib.rs +++ b/crates/apple/src/lib.rs @@ -16,7 +16,7 @@ use ironstorage::{ MobileAuthenticationError as StorageAuthenticationError, MobileAuthenticationErrorKind as StorageAuthenticationErrorKind, MobileAuthenticationState as StorageAuthenticationState, - MobileEntryCopy as StorageEntryCopy, + MobileEntryCopy as StorageEntryCopy, MobileEntryPresentation as StorageEntryPresentation, }, mobile_entry::{ MobileEntryEditorFieldKind as StorageEntryEditorFieldKind, @@ -923,6 +923,24 @@ impl From for MobileTotpDetail { } } +#[derive(Clone, uniffi::Record)] +pub struct MobileEntryPresentation { + pub page: MobileEntryPage, + pub totp: Option, + pub cache_notice: Option, +} + +impl From for MobileEntryPresentation { + fn from(presentation: StorageEntryPresentation) -> Self { + let (page, totp, cache_notice) = presentation.into_parts(); + Self { + page: page.into(), + totp: totp.map(Into::into), + cache_notice, + } + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] pub enum MobileEntryEditorFieldKind { Password, @@ -1344,9 +1362,10 @@ impl MobileAuthentication { pub fn entry_page( &self, path: String, - ) -> Result { + unix_seconds: u64, + ) -> Result { self.authentication - .entry_page(&path) + .entry_page(&path, unix_seconds) .map(Into::into) .map_err(Into::into) } diff --git a/crates/storage/src/document.rs b/crates/storage/src/document.rs index 3112cd1..37a945f 100644 --- a/crates/storage/src/document.rs +++ b/crates/storage/src/document.rs @@ -7,9 +7,9 @@ use sha2::{Digest as _, Sha256}; use crate::{ command::EditRequest, crypto::{KeyStore, SecretProvider}, - otp::{OtpAlgorithm, OtpKind, OtpUri}, + otp::{OtpAlgorithm, OtpError, OtpKind, OtpUri}, recipient::SigningPolicy, - repository::{EntryPath, Repository, SecretBytes}, + repository::{EncryptedEntry, EntryPath, Repository, SecretBytes}, write::{EditSession, EntryCommitter, VaultWriter, WriteError, WriteOutcome}, }; @@ -424,6 +424,15 @@ impl EntryDocument { SecretBytes::new(output) } + pub(crate) fn original_ciphertext(&self) -> Option<&EncryptedEntry> { + self.session.original_ciphertext() + } + + pub(crate) fn otp_uri(&self) -> Result, OtpError> { + let plaintext = self.serialize(); + crate::otp::find_uri(&plaintext, &self.path).map(|found| found.map(|(_, uri)| uri)) + } + fn has_final_newline(&self) -> bool { self.fields .last() diff --git a/crates/storage/src/mobile_authentication.rs b/crates/storage/src/mobile_authentication.rs index ce6eae8..2e6592e 100644 --- a/crates/storage/src/mobile_authentication.rs +++ b/crates/storage/src/mobile_authentication.rs @@ -145,6 +145,30 @@ impl MobileEntryCopy { } } +pub struct MobileEntryPresentation { + page: MobileEntryPage, + totp: Option, + cache_notice: Option, +} + +impl MobileEntryPresentation { + pub fn page(&self) -> &MobileEntryPage { + &self.page + } + + pub fn totp(&self) -> Option<&MobileTotpDetail> { + self.totp.as_ref() + } + + pub fn cache_notice(&self) -> Option<&str> { + self.cache_notice.as_deref() + } + + pub fn into_parts(self) -> (MobileEntryPage, Option, Option) { + (self.page, self.totp, self.cache_notice) + } +} + impl MobileAuthenticationState { pub fn unlocked(self) -> bool { self.unlocked @@ -385,9 +409,32 @@ impl MobileAuthentication { .map_err(MobileAuthenticationError::authentication) } - pub fn entry_page(&self, path: &str) -> Result { + pub fn entry_page( + &self, + path: &str, + unix_seconds: u64, + ) -> Result { let document = self.open_active_document(path)?; - Ok(MobileEntryPage::from_document(&document)) + let ciphertext = document + .original_ciphertext() + .ok_or_else(|| entry_detail("Password Entry Is Unavailable", "entry does not exist"))?; + let shared = self.status()?.watch_shared_totp_entries.clone(); + let cache_path = self.config.source().with_file_name("totp-catalog.toml"); + let reconciliation = MobileTotpService::new(&self.repository, &self.keys) + .reconcile_document( + &document, + ciphertext, + Some(unix_seconds), + &shared, + &cache_path, + ) + .map_err(totp_error)?; + let (totp, cache_notice) = reconciliation.into_parts(); + Ok(MobileEntryPresentation { + page: MobileEntryPage::from_document(&document), + totp, + cache_notice, + }) } pub fn reveal_entry_field( @@ -591,6 +638,7 @@ impl MobileAuthentication { EntryDocumentService::new(&self.repository, &self.keys) .save_recoverable(&document, None, &mut committer) .map_err(document_error)?; + self.reconcile_saved_document(&document); Ok(MobileEntryPage::from_document(&document)) } @@ -764,6 +812,7 @@ impl MobileAuthentication { self.restore_editor(editor, draft)?; return Err(document_error(error)); } + self.reconcile_saved_document(draft.document()); Ok(MobileEntryPage::from_document(draft.document())) } @@ -954,6 +1003,25 @@ impl MobileAuthentication { .open(path, &mut provider) .map_err(document_error) } + + fn reconcile_saved_document(&self, document: &EntryDocument) { + let Ok(ciphertext) = self.repository.read_entry(document.path()) else { + return; + }; + let Ok(status) = self.status() else { + return; + }; + let shared = status.watch_shared_totp_entries.clone(); + drop(status); + let cache_path = self.config.source().with_file_name("totp-catalog.toml"); + let _ = MobileTotpService::new(&self.repository, &self.keys).reconcile_document( + document, + &ciphertext, + None, + &shared, + &cache_path, + ); + } } struct KeyOnlyProvider<'a> { diff --git a/crates/storage/src/mobile_totp.rs b/crates/storage/src/mobile_totp.rs index c66c34f..886f6b7 100644 --- a/crates/storage/src/mobile_totp.rs +++ b/crates/storage/src/mobile_totp.rs @@ -20,11 +20,14 @@ use sha2::{Digest as _, Sha256}; use crate::{ crypto::{KeyStore, SecretProvider}, + document::EntryDocument, otp::{OtpError, OtpKind, OtpService}, repository::{EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes}, }; const CACHE_VERSION: u32 = 1; +// ponytail: cache writes are rare; use per-cache locks only if profiling shows contention. +static CACHE_WRITE_LOCK: Mutex<()> = Mutex::new(()); #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum MobileTotpDiscoveryPhase { @@ -266,6 +269,25 @@ impl fmt::Debug for MobileTotpDetail { } } +pub struct MobileTotpReconciliation { + detail: Option, + cache_notice: Option, +} + +impl MobileTotpReconciliation { + pub fn detail(&self) -> Option<&MobileTotpDetail> { + self.detail.as_ref() + } + + pub fn cache_notice(&self) -> Option<&str> { + self.cache_notice.as_deref() + } + + pub fn into_parts(self) -> (Option, Option) { + (self.detail, self.cache_notice) + } +} + pub struct MobileTotpService<'a> { repository: &'a Repository, keys: &'a KeyStore, @@ -328,13 +350,6 @@ impl<'a> MobileTotpService<'a> { let store = store_identity(self.repository.root_path()); let (cached, mut cache_notice) = load_cache(cache_path, &store); - let mut checkpoint = CachedCatalog { - version: CACHE_VERSION, - store: store.clone(), - entries: cached - .as_ref() - .map_or_else(BTreeMap::new, |catalog| catalog.entries.clone()), - }; let mut records = BTreeMap::new(); let mut rows = Vec::new(); let mut unavailable_entries = 0_u32; @@ -371,10 +386,9 @@ impl<'a> MobileTotpService<'a> { operation.update(|progress| progress.matches = progress.matches.saturating_add(1)); } if changed { - checkpoint.entries.insert(path_text.clone(), record.clone()); - if let Err(error) = save_cache(cache_path, &checkpoint) { - cache_notice = Some(error.to_string()); - } + cache_notice = + upsert_cache_record(cache_path, &store, path_text.clone(), record.clone()) + .or(cache_notice); } records.insert(path_text, record); operation.update(|progress| progress.inspected = progress.inspected.saturating_add(1)); @@ -429,6 +443,38 @@ impl<'a> MobileTotpService<'a> { }) } + pub fn reconcile_document( + &self, + document: &EntryDocument, + ciphertext: &EncryptedEntry, + unix_seconds: Option, + shared: &BTreeSet, + cache_path: &Path, + ) -> Result { + let uri = document + .otp_uri() + .ok() + .flatten() + .filter(|uri| uri.kind() == OtpKind::Totp); + let record = CachedRecord { + ciphertext_hash: digest(ciphertext.as_bytes()), + is_totp: uri.is_some(), + }; + let store = store_identity(self.repository.root_path()); + let cache_notice = + upsert_cache_record(cache_path, &store, document.path().to_string(), record); + let detail = match (uri, unix_seconds) { + (Some(uri), Some(unix_seconds)) => { + Some(detail_for_uri(document.path(), uri, unix_seconds, shared)?) + } + _ => None, + }; + Ok(MobileTotpReconciliation { + detail, + cache_notice, + }) + } + pub fn detail( &self, entry: &str, @@ -441,25 +487,33 @@ impl<'a> MobileTotpService<'a> { if uri.kind() != OtpKind::Totp { return Err(OtpError::NotTotp.into()); } - let period = uri.period().ok_or(OtpError::NotTotp)?; - let valid_until = (unix_seconds / period) - .checked_add(1) - .and_then(|counter| counter.checked_mul(period)) - .ok_or(OtpError::CounterOverflow)?; - let shared_with_watch = shared.contains(&path); - Ok(MobileTotpDetail { - path: path.to_string(), - issuer: uri.issuer().map(str::to_owned), - account: uri.account().to_owned(), - code: uri.code_at(unix_seconds)?, - valid_until, - period, - shared_with_watch, - watch: snapshot_status(shared.len()), - }) + detail_for_uri(&path, uri, unix_seconds, shared) } } +fn detail_for_uri( + path: &EntryPath, + uri: crate::otp::OtpUri, + unix_seconds: u64, + shared: &BTreeSet, +) -> Result { + let period = uri.period().ok_or(OtpError::NotTotp)?; + let valid_until = (unix_seconds / period) + .checked_add(1) + .and_then(|counter| counter.checked_mul(period)) + .ok_or(OtpError::CounterOverflow)?; + Ok(MobileTotpDetail { + path: path.to_string(), + issuer: uri.issuer().map(str::to_owned), + account: uri.account().to_owned(), + code: uri.code_at(unix_seconds)?, + valid_until, + period, + shared_with_watch: shared.contains(path), + watch: snapshot_status(shared.len()), + }) +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] struct CachedRecord { ciphertext_hash: String, @@ -545,7 +599,39 @@ fn load_cache(path: &Path, store: &str) -> (Option, Option Option { + let _write = CACHE_WRITE_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let (catalog, notice) = load_cache(path, store); + let mut catalog = catalog.unwrap_or_else(|| CachedCatalog { + version: CACHE_VERSION, + store: store.to_owned(), + entries: BTreeMap::new(), + }); + if catalog.entries.get(&entry) == Some(&record) { + return notice; + } + catalog.entries.insert(entry, record); + save_cache_unlocked(path, &catalog) + .err() + .map(|error| error.to_string()) + .or(notice) +} + fn save_cache(path: &Path, catalog: &CachedCatalog) -> Result<(), MobileTotpCacheError> { + let _write = CACHE_WRITE_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + save_cache_unlocked(path, catalog) +} + +fn save_cache_unlocked(path: &Path, catalog: &CachedCatalog) -> Result<(), MobileTotpCacheError> { let serialized = toml::to_string(catalog).map_err(|_| MobileTotpCacheError::Encode)?; let parent = path.parent().ok_or(MobileTotpCacheError::Write)?; let name = path.file_name().ok_or(MobileTotpCacheError::Write)?; diff --git a/crates/storage/src/otp.rs b/crates/storage/src/otp.rs index 2e234a1..dbc31ac 100644 --- a/crates/storage/src/otp.rs +++ b/crates/storage/src/otp.rs @@ -1213,7 +1213,7 @@ where Ok(digest) } -fn find_uri( +pub(crate) fn find_uri( plaintext: &SecretBytes, entry: &EntryPath, ) -> Result, OtpUri)>, OtpError> { diff --git a/crates/storage/tests/mobile_totp.rs b/crates/storage/tests/mobile_totp.rs index c2807d0..3a1e1a9 100644 --- a/crates/storage/tests/mobile_totp.rs +++ b/crates/storage/tests/mobile_totp.rs @@ -9,6 +9,7 @@ use std::{ use ironstorage::{ crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError}, + document::EntryDocumentService, mobile_totp::{ MobileTotpDiscoveryPhase, MobileTotpError, MobileTotpOperation, MobileTotpService, MobileWatchSnapshotState, @@ -305,6 +306,72 @@ fn totp_cache_reuses_ciphertext_hashes_and_removes_deleted_entries() -> TestResu Ok(()) } +#[test] +fn decrypted_entry_details_reconcile_totp_cache_without_persisting_secrets() -> TestResult { + let fixture = FixtureSet::load()?; + let store = fixture.materialize_store("basic")?; + let repository = Repository::open(store.path())?; + let keys = KeyStore::load(fixture.path("keys"))?; + let path = EntryPath::parse("otp/detail")?; + write_plaintext( + &repository, + &keys, + "otp/detail", + b"password\notpauth://totp/Acme:detail@example.com?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Acme&digits=8&period=30\n", + )?; + let cache_directory = tempfile::tempdir()?; + let cache = cache_directory.path().join("totp-catalog.toml"); + let service = MobileTotpService::new(&repository, &keys); + let mut secrets = FixtureSecrets::all(&fixture); + let document = + EntryDocumentService::new(&repository, &keys).open("otp/detail", &mut secrets)?; + let ciphertext = repository.read_entry(&path)?; + + let reconciliation = + service.reconcile_document(&document, &ciphertext, Some(59), &BTreeSet::new(), &cache)?; + let detail = reconciliation.detail().expect("valid TOTP detail"); + assert_eq!(detail.code().expose(), b"94287082"); + assert_eq!(detail.valid_until(), 60); + assert!( + service + .cached_page(&BTreeSet::new(), &cache) + .expect("detail created cache") + .rows() + .iter() + .any(|row| row.path() == "otp/detail") + ); + let encoded = fs::read_to_string(&cache)?; + assert!(encoded.contains("otp/detail")); + assert!(encoded.contains("is_totp = true")); + for secret in ["otpauth://", "secret=", "detail@example.com", "password"] { + assert!(!encoded.contains(secret), "cache leaked {secret}"); + } + + write_plaintext( + &repository, + &keys, + "otp/detail", + b"password\nlogin: detail\n", + )?; + let mut secrets = FixtureSecrets::all(&fixture); + let document = + EntryDocumentService::new(&repository, &keys).open("otp/detail", &mut secrets)?; + let ciphertext = repository.read_entry(&path)?; + let reconciliation = + service.reconcile_document(&document, &ciphertext, Some(59), &BTreeSet::new(), &cache)?; + assert!(reconciliation.detail().is_none()); + assert!( + service + .cached_page(&BTreeSet::new(), &cache) + .expect("detail updated cache") + .rows() + .iter() + .all(|row| row.path() != "otp/detail") + ); + assert!(fs::read_to_string(cache)?.contains("is_totp = false")); + Ok(()) +} + #[test] fn cancelled_discovery_checkpoints_completed_entries() -> TestResult { let fixture = FixtureSet::load()?;