Show TOTP codes in password details (#75)
This commit is contained in:
@@ -621,7 +621,7 @@ public protocol MobileAuthenticationProtocol: AnyObject, Sendable {
|
|||||||
|
|
||||||
func entryEditor(editor: UInt64) throws -> MobileEntryEditorPage
|
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
|
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 {
|
open func entryPage(path: String, unixSeconds: UInt64)throws -> MobileEntryPresentation {
|
||||||
return try FfiConverterTypeMobileEntryPage_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
return try FfiConverterTypeMobileEntryPresentation_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
||||||
uniffiCallStatus in
|
uniffiCallStatus in
|
||||||
uniffi_ironstorage_apple_fn_method_mobileauthentication_entry_page(
|
uniffi_ironstorage_apple_fn_method_mobileauthentication_entry_page(
|
||||||
self.uniffiCloneHandle(),
|
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 struct MobileEntrySection: Equatable, Hashable {
|
||||||
public var kind: MobileEntrySectionKind
|
public var kind: MobileEntrySectionKind
|
||||||
public var title: String
|
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)
|
#if swift(>=5.8)
|
||||||
@_documentation(visibility: private)
|
@_documentation(visibility: private)
|
||||||
#endif
|
#endif
|
||||||
@@ -6775,7 +6858,7 @@ private let initializationResult: InitializationResult = {
|
|||||||
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_entry_editor() != 65179) {
|
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_entry_editor() != 65179) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
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
|
return InitializationResult.apiChecksumMismatch
|
||||||
}
|
}
|
||||||
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_generate_entry_editor_password() != 34289) {
|
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_generate_entry_editor_password() != 34289) {
|
||||||
|
|||||||
@@ -300,7 +300,7 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_entry_editor(
|
|||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_ENTRY_PAGE
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_ENTRY_PAGE
|
||||||
#define 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
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_GENERATE_ENTRY_EDITOR_PASSWORD
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_GENERATE_ENTRY_EDITOR_PASSWORD
|
||||||
|
|||||||
@@ -2150,15 +2150,111 @@ private final class TotpListViewController: UITableViewController, MobileTabRoot
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private final class TotpDetailViewController: UITableViewController {
|
private final class TotpCodeView: UIView {
|
||||||
private let authentication: MobileAuthentication
|
|
||||||
private var detail: MobileTotpDetail
|
|
||||||
private let codeContainer = UIView()
|
|
||||||
private let issuerLabel = UILabel()
|
private let issuerLabel = UILabel()
|
||||||
private let accountLabel = UILabel()
|
private let accountLabel = UILabel()
|
||||||
private let codeLabel = UILabel()
|
private let codeLabel = UILabel()
|
||||||
private let countdownLabel = UILabel()
|
private let countdownLabel = UILabel()
|
||||||
private let progress = UIProgressView(progressViewStyle: .default)
|
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 let watchSwitch = UISwitch()
|
||||||
private var timerTask: Task<Void, Never>?
|
private var timerTask: Task<Void, Never>?
|
||||||
private var loadTask: Task<Void, Never>?
|
private var loadTask: Task<Void, Never>?
|
||||||
@@ -2354,56 +2450,15 @@ private final class TotpDetailViewController: UITableViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func configureHeader() {
|
private func configureHeader() {
|
||||||
issuerLabel.font = .preferredFont(forTextStyle: .title2)
|
codeView.frame = CGRect(x: 0, y: 0, width: tableView.bounds.width, height: 1)
|
||||||
issuerLabel.adjustsFontForContentSizeCategory = true
|
codeView.copyRequested = { [weak self] in self?.copyRequested() }
|
||||||
issuerLabel.textAlignment = .center
|
tableView.tableHeaderView = codeView
|
||||||
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))
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func apply(_ detail: MobileTotpDetail) {
|
private func apply(_ detail: MobileTotpDetail) {
|
||||||
self.detail = detail
|
self.detail = detail
|
||||||
title = detail.issuer ?? detail.account
|
title = detail.issuer ?? detail.account
|
||||||
issuerLabel.text = detail.issuer ?? "TOTP"
|
codeView.apply(detail)
|
||||||
accountLabel.text = detail.account
|
|
||||||
codeLabel.text = groupedCode(detail.code)
|
|
||||||
codeLabel.accessibilityLabel = "Current code \(detail.code.map(String.init).joined(separator: " "))"
|
|
||||||
watchSwitch.setOn(detail.sharedWithWatch, animated: false)
|
watchSwitch.setOn(detail.sharedWithWatch, animated: false)
|
||||||
tableView.reloadData()
|
tableView.reloadData()
|
||||||
updateCountdown()
|
updateCountdown()
|
||||||
@@ -2429,12 +2484,7 @@ private final class TotpDetailViewController: UITableViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func updateCountdown() {
|
private func updateCountdown() {
|
||||||
let remaining = detail.validUntil.saturatingSubtracting(currentUnixSeconds())
|
codeView.updateCountdown(detail)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func refreshCode() {
|
private func refreshCode() {
|
||||||
@@ -2467,13 +2517,7 @@ private final class TotpDetailViewController: UITableViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func flashCopied() {
|
private func flashCopied() {
|
||||||
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
codeView.flashCopied()
|
||||||
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 }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handle(_ failure: AuthenticationFailure) {
|
private func handle(_ failure: AuthenticationFailure) {
|
||||||
@@ -2489,7 +2533,7 @@ private final class TotpDetailViewController: UITableViewController {
|
|||||||
} else {
|
} else {
|
||||||
timerTask?.cancel()
|
timerTask?.cancel()
|
||||||
detail.code = ""
|
detail.code = ""
|
||||||
codeLabel.text = nil
|
codeView.clear()
|
||||||
navigationItem.rightBarButtonItem = nil
|
navigationItem.rightBarButtonItem = nil
|
||||||
watchSwitch.isEnabled = false
|
watchSwitch.isEnabled = false
|
||||||
var configuration = UIContentUnavailableConfiguration.empty()
|
var configuration = UIContentUnavailableConfiguration.empty()
|
||||||
@@ -2511,7 +2555,7 @@ private final class TotpDetailViewController: UITableViewController {
|
|||||||
loadTask?.cancel()
|
loadTask?.cancel()
|
||||||
clipboardTask?.cancel()
|
clipboardTask?.cancel()
|
||||||
detail.code = ""
|
detail.code = ""
|
||||||
codeLabel.text = nil
|
codeView.clear()
|
||||||
if UIPasteboard.general.string == copiedValue {
|
if UIPasteboard.general.string == copiedValue {
|
||||||
UIPasteboard.general.items = []
|
UIPasteboard.general.items = []
|
||||||
}
|
}
|
||||||
@@ -3646,12 +3690,18 @@ private struct PasswordFailure: Error, Sendable {
|
|||||||
private final class LockedPasswordViewController: UITableViewController {
|
private final class LockedPasswordViewController: UITableViewController {
|
||||||
private let entry: MobilePasswordRow
|
private let entry: MobilePasswordRow
|
||||||
private let authentication: MobileAuthentication?
|
private let authentication: MobileAuthentication?
|
||||||
|
private let totpCodeView = TotpCodeView()
|
||||||
private var unlockTask: Task<Void, Never>?
|
private var unlockTask: Task<Void, Never>?
|
||||||
private var entryTask: Task<Void, Never>?
|
private var entryTask: Task<Void, Never>?
|
||||||
|
private var totpTask: Task<Void, Never>?
|
||||||
|
private var totpTimerTask: Task<Void, Never>?
|
||||||
private var clipboardTask: Task<Void, Never>?
|
private var clipboardTask: Task<Void, Never>?
|
||||||
private var feedbackTask: Task<Void, Never>?
|
private var feedbackTask: Task<Void, Never>?
|
||||||
private var state: MobileAuthenticationState?
|
private var state: MobileAuthenticationState?
|
||||||
private var page: MobileEntryPage?
|
private var page: MobileEntryPage?
|
||||||
|
private var totp: MobileTotpDetail?
|
||||||
|
private var cacheNotice: String?
|
||||||
|
private var copiedTotpValue: String?
|
||||||
private var revealedValues: [UInt64: String] = [:]
|
private var revealedValues: [UInt64: String] = [:]
|
||||||
|
|
||||||
init(entry: MobilePasswordRow, authentication: MobileAuthentication?) {
|
init(entry: MobilePasswordRow, authentication: MobileAuthentication?) {
|
||||||
@@ -3685,6 +3735,8 @@ private final class LockedPasswordViewController: UITableViewController {
|
|||||||
deinit {
|
deinit {
|
||||||
unlockTask?.cancel()
|
unlockTask?.cancel()
|
||||||
entryTask?.cancel()
|
entryTask?.cancel()
|
||||||
|
totpTask?.cancel()
|
||||||
|
totpTimerTask?.cancel()
|
||||||
clipboardTask?.cancel()
|
clipboardTask?.cancel()
|
||||||
feedbackTask?.cancel()
|
feedbackTask?.cancel()
|
||||||
NotificationCenter.default.removeObserver(self)
|
NotificationCenter.default.removeObserver(self)
|
||||||
@@ -3695,6 +3747,19 @@ private final class LockedPasswordViewController: UITableViewController {
|
|||||||
refreshState()
|
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 {
|
override func numberOfSections(in tableView: UITableView) -> Int {
|
||||||
page?.sections.count ?? 0
|
page?.sections.count ?? 0
|
||||||
}
|
}
|
||||||
@@ -3713,6 +3778,14 @@ private final class LockedPasswordViewController: UITableViewController {
|
|||||||
page?.sections[section].title
|
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(
|
override func tableView(
|
||||||
_ tableView: UITableView,
|
_ tableView: UITableView,
|
||||||
cellForRowAt indexPath: IndexPath
|
cellForRowAt indexPath: IndexPath
|
||||||
@@ -3853,8 +3926,11 @@ private final class LockedPasswordViewController: UITableViewController {
|
|||||||
entryTask = Task { [weak self] in
|
entryTask = Task { [weak self] in
|
||||||
let result = await Task.detached(priority: .userInitiated) {
|
let result = await Task.detached(priority: .userInitiated) {
|
||||||
do {
|
do {
|
||||||
return Result<MobileEntryPage, AuthenticationFailure>.success(
|
return Result<MobileEntryPresentation, AuthenticationFailure>.success(
|
||||||
try authentication.entryPage(path: path)
|
try authentication.entryPage(
|
||||||
|
path: path,
|
||||||
|
unixSeconds: currentUnixSeconds()
|
||||||
|
)
|
||||||
)
|
)
|
||||||
} catch let error as MobileAuthenticationFfiError {
|
} catch let error as MobileAuthenticationFfiError {
|
||||||
return .failure(AuthenticationFailure(error))
|
return .failure(AuthenticationFailure(error))
|
||||||
@@ -3864,11 +3940,14 @@ private final class LockedPasswordViewController: UITableViewController {
|
|||||||
}.value
|
}.value
|
||||||
guard !Task.isCancelled, let self else { return }
|
guard !Task.isCancelled, let self else { return }
|
||||||
switch result {
|
switch result {
|
||||||
case let .success(page):
|
case let .success(presentation):
|
||||||
self.page = page
|
page = presentation.page
|
||||||
title = page.title
|
totp = presentation.totp
|
||||||
|
cacheNotice = presentation.cacheNotice
|
||||||
|
title = presentation.page.title
|
||||||
contentUnavailableConfiguration = nil
|
contentUnavailableConfiguration = nil
|
||||||
installLockButton()
|
installLockButton()
|
||||||
|
configureTotpHeader()
|
||||||
tableView.reloadData()
|
tableView.reloadData()
|
||||||
case let .failure(failure):
|
case let .failure(failure):
|
||||||
handle(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<MobileTotpDetail, AuthenticationFailure>.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<MobileEntryCopy, AuthenticationFailure>.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) {
|
private func edit(_ field: MobileEntryField) {
|
||||||
guard let authentication else { return }
|
guard let authentication else { return }
|
||||||
entryTask?.cancel()
|
entryTask?.cancel()
|
||||||
@@ -4010,8 +4198,18 @@ private final class LockedPasswordViewController: UITableViewController {
|
|||||||
|
|
||||||
private func maskAndDiscardEntry() {
|
private func maskAndDiscardEntry() {
|
||||||
entryTask?.cancel()
|
entryTask?.cancel()
|
||||||
|
totpTask?.cancel()
|
||||||
|
totpTimerTask?.cancel()
|
||||||
feedbackTask?.cancel()
|
feedbackTask?.cancel()
|
||||||
page = nil
|
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)
|
revealedValues.removeAll(keepingCapacity: false)
|
||||||
tableView.reloadData()
|
tableView.reloadData()
|
||||||
navigationItem.rightBarButtonItem = nil
|
navigationItem.rightBarButtonItem = nil
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use ironstorage::{
|
|||||||
MobileAuthenticationError as StorageAuthenticationError,
|
MobileAuthenticationError as StorageAuthenticationError,
|
||||||
MobileAuthenticationErrorKind as StorageAuthenticationErrorKind,
|
MobileAuthenticationErrorKind as StorageAuthenticationErrorKind,
|
||||||
MobileAuthenticationState as StorageAuthenticationState,
|
MobileAuthenticationState as StorageAuthenticationState,
|
||||||
MobileEntryCopy as StorageEntryCopy,
|
MobileEntryCopy as StorageEntryCopy, MobileEntryPresentation as StorageEntryPresentation,
|
||||||
},
|
},
|
||||||
mobile_entry::{
|
mobile_entry::{
|
||||||
MobileEntryEditorFieldKind as StorageEntryEditorFieldKind,
|
MobileEntryEditorFieldKind as StorageEntryEditorFieldKind,
|
||||||
@@ -923,6 +923,24 @@ impl From<StorageTotpDetail> for MobileTotpDetail {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct MobileEntryPresentation {
|
||||||
|
pub page: MobileEntryPage,
|
||||||
|
pub totp: Option<MobileTotpDetail>,
|
||||||
|
pub cache_notice: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<StorageEntryPresentation> 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)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||||
pub enum MobileEntryEditorFieldKind {
|
pub enum MobileEntryEditorFieldKind {
|
||||||
Password,
|
Password,
|
||||||
@@ -1344,9 +1362,10 @@ impl MobileAuthentication {
|
|||||||
pub fn entry_page(
|
pub fn entry_page(
|
||||||
&self,
|
&self,
|
||||||
path: String,
|
path: String,
|
||||||
) -> Result<MobileEntryPage, MobileAuthenticationFfiError> {
|
unix_seconds: u64,
|
||||||
|
) -> Result<MobileEntryPresentation, MobileAuthenticationFfiError> {
|
||||||
self.authentication
|
self.authentication
|
||||||
.entry_page(&path)
|
.entry_page(&path, unix_seconds)
|
||||||
.map(Into::into)
|
.map(Into::into)
|
||||||
.map_err(Into::into)
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ use sha2::{Digest as _, Sha256};
|
|||||||
use crate::{
|
use crate::{
|
||||||
command::EditRequest,
|
command::EditRequest,
|
||||||
crypto::{KeyStore, SecretProvider},
|
crypto::{KeyStore, SecretProvider},
|
||||||
otp::{OtpAlgorithm, OtpKind, OtpUri},
|
otp::{OtpAlgorithm, OtpError, OtpKind, OtpUri},
|
||||||
recipient::SigningPolicy,
|
recipient::SigningPolicy,
|
||||||
repository::{EntryPath, Repository, SecretBytes},
|
repository::{EncryptedEntry, EntryPath, Repository, SecretBytes},
|
||||||
write::{EditSession, EntryCommitter, VaultWriter, WriteError, WriteOutcome},
|
write::{EditSession, EntryCommitter, VaultWriter, WriteError, WriteOutcome},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -424,6 +424,15 @@ impl EntryDocument {
|
|||||||
SecretBytes::new(output)
|
SecretBytes::new(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn original_ciphertext(&self) -> Option<&EncryptedEntry> {
|
||||||
|
self.session.original_ciphertext()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn otp_uri(&self) -> Result<Option<OtpUri>, OtpError> {
|
||||||
|
let plaintext = self.serialize();
|
||||||
|
crate::otp::find_uri(&plaintext, &self.path).map(|found| found.map(|(_, uri)| uri))
|
||||||
|
}
|
||||||
|
|
||||||
fn has_final_newline(&self) -> bool {
|
fn has_final_newline(&self) -> bool {
|
||||||
self.fields
|
self.fields
|
||||||
.last()
|
.last()
|
||||||
|
|||||||
@@ -145,6 +145,30 @@ impl MobileEntryCopy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct MobileEntryPresentation {
|
||||||
|
page: MobileEntryPage,
|
||||||
|
totp: Option<MobileTotpDetail>,
|
||||||
|
cache_notice: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<MobileTotpDetail>, Option<String>) {
|
||||||
|
(self.page, self.totp, self.cache_notice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl MobileAuthenticationState {
|
impl MobileAuthenticationState {
|
||||||
pub fn unlocked(self) -> bool {
|
pub fn unlocked(self) -> bool {
|
||||||
self.unlocked
|
self.unlocked
|
||||||
@@ -385,9 +409,32 @@ impl MobileAuthentication {
|
|||||||
.map_err(MobileAuthenticationError::authentication)
|
.map_err(MobileAuthenticationError::authentication)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn entry_page(&self, path: &str) -> Result<MobileEntryPage, MobileAuthenticationError> {
|
pub fn entry_page(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
unix_seconds: u64,
|
||||||
|
) -> Result<MobileEntryPresentation, MobileAuthenticationError> {
|
||||||
let document = self.open_active_document(path)?;
|
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(
|
pub fn reveal_entry_field(
|
||||||
@@ -591,6 +638,7 @@ impl MobileAuthentication {
|
|||||||
EntryDocumentService::new(&self.repository, &self.keys)
|
EntryDocumentService::new(&self.repository, &self.keys)
|
||||||
.save_recoverable(&document, None, &mut committer)
|
.save_recoverable(&document, None, &mut committer)
|
||||||
.map_err(document_error)?;
|
.map_err(document_error)?;
|
||||||
|
self.reconcile_saved_document(&document);
|
||||||
Ok(MobileEntryPage::from_document(&document))
|
Ok(MobileEntryPage::from_document(&document))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -764,6 +812,7 @@ impl MobileAuthentication {
|
|||||||
self.restore_editor(editor, draft)?;
|
self.restore_editor(editor, draft)?;
|
||||||
return Err(document_error(error));
|
return Err(document_error(error));
|
||||||
}
|
}
|
||||||
|
self.reconcile_saved_document(draft.document());
|
||||||
Ok(MobileEntryPage::from_document(draft.document()))
|
Ok(MobileEntryPage::from_document(draft.document()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -954,6 +1003,25 @@ impl MobileAuthentication {
|
|||||||
.open(path, &mut provider)
|
.open(path, &mut provider)
|
||||||
.map_err(document_error)
|
.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> {
|
struct KeyOnlyProvider<'a> {
|
||||||
|
|||||||
@@ -20,11 +20,14 @@ use sha2::{Digest as _, Sha256};
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
crypto::{KeyStore, SecretProvider},
|
crypto::{KeyStore, SecretProvider},
|
||||||
|
document::EntryDocument,
|
||||||
otp::{OtpError, OtpKind, OtpService},
|
otp::{OtpError, OtpKind, OtpService},
|
||||||
repository::{EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes},
|
repository::{EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes},
|
||||||
};
|
};
|
||||||
|
|
||||||
const CACHE_VERSION: u32 = 1;
|
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)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub enum MobileTotpDiscoveryPhase {
|
pub enum MobileTotpDiscoveryPhase {
|
||||||
@@ -266,6 +269,25 @@ impl fmt::Debug for MobileTotpDetail {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct MobileTotpReconciliation {
|
||||||
|
detail: Option<MobileTotpDetail>,
|
||||||
|
cache_notice: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<MobileTotpDetail>, Option<String>) {
|
||||||
|
(self.detail, self.cache_notice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct MobileTotpService<'a> {
|
pub struct MobileTotpService<'a> {
|
||||||
repository: &'a Repository,
|
repository: &'a Repository,
|
||||||
keys: &'a KeyStore,
|
keys: &'a KeyStore,
|
||||||
@@ -328,13 +350,6 @@ impl<'a> MobileTotpService<'a> {
|
|||||||
|
|
||||||
let store = store_identity(self.repository.root_path());
|
let store = store_identity(self.repository.root_path());
|
||||||
let (cached, mut cache_notice) = load_cache(cache_path, &store);
|
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 records = BTreeMap::new();
|
||||||
let mut rows = Vec::new();
|
let mut rows = Vec::new();
|
||||||
let mut unavailable_entries = 0_u32;
|
let mut unavailable_entries = 0_u32;
|
||||||
@@ -371,10 +386,9 @@ impl<'a> MobileTotpService<'a> {
|
|||||||
operation.update(|progress| progress.matches = progress.matches.saturating_add(1));
|
operation.update(|progress| progress.matches = progress.matches.saturating_add(1));
|
||||||
}
|
}
|
||||||
if changed {
|
if changed {
|
||||||
checkpoint.entries.insert(path_text.clone(), record.clone());
|
cache_notice =
|
||||||
if let Err(error) = save_cache(cache_path, &checkpoint) {
|
upsert_cache_record(cache_path, &store, path_text.clone(), record.clone())
|
||||||
cache_notice = Some(error.to_string());
|
.or(cache_notice);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
records.insert(path_text, record);
|
records.insert(path_text, record);
|
||||||
operation.update(|progress| progress.inspected = progress.inspected.saturating_add(1));
|
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<u64>,
|
||||||
|
shared: &BTreeSet<EntryPath>,
|
||||||
|
cache_path: &Path,
|
||||||
|
) -> Result<MobileTotpReconciliation, MobileTotpError> {
|
||||||
|
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(
|
pub fn detail(
|
||||||
&self,
|
&self,
|
||||||
entry: &str,
|
entry: &str,
|
||||||
@@ -441,25 +487,33 @@ impl<'a> MobileTotpService<'a> {
|
|||||||
if uri.kind() != OtpKind::Totp {
|
if uri.kind() != OtpKind::Totp {
|
||||||
return Err(OtpError::NotTotp.into());
|
return Err(OtpError::NotTotp.into());
|
||||||
}
|
}
|
||||||
let period = uri.period().ok_or(OtpError::NotTotp)?;
|
detail_for_uri(&path, uri, unix_seconds, shared)
|
||||||
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()),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn detail_for_uri(
|
||||||
|
path: &EntryPath,
|
||||||
|
uri: crate::otp::OtpUri,
|
||||||
|
unix_seconds: u64,
|
||||||
|
shared: &BTreeSet<EntryPath>,
|
||||||
|
) -> Result<MobileTotpDetail, MobileTotpError> {
|
||||||
|
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)]
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
struct CachedRecord {
|
struct CachedRecord {
|
||||||
ciphertext_hash: String,
|
ciphertext_hash: String,
|
||||||
@@ -545,7 +599,39 @@ fn load_cache(path: &Path, store: &str) -> (Option<CachedCatalog>, Option<String
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn upsert_cache_record(
|
||||||
|
path: &Path,
|
||||||
|
store: &str,
|
||||||
|
entry: String,
|
||||||
|
record: CachedRecord,
|
||||||
|
) -> Option<String> {
|
||||||
|
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> {
|
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 serialized = toml::to_string(catalog).map_err(|_| MobileTotpCacheError::Encode)?;
|
||||||
let parent = path.parent().ok_or(MobileTotpCacheError::Write)?;
|
let parent = path.parent().ok_or(MobileTotpCacheError::Write)?;
|
||||||
let name = path.file_name().ok_or(MobileTotpCacheError::Write)?;
|
let name = path.file_name().ok_or(MobileTotpCacheError::Write)?;
|
||||||
|
|||||||
@@ -1213,7 +1213,7 @@ where
|
|||||||
Ok(digest)
|
Ok(digest)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn find_uri(
|
pub(crate) fn find_uri(
|
||||||
plaintext: &SecretBytes,
|
plaintext: &SecretBytes,
|
||||||
entry: &EntryPath,
|
entry: &EntryPath,
|
||||||
) -> Result<Option<(Range<usize>, OtpUri)>, OtpError> {
|
) -> Result<Option<(Range<usize>, OtpUri)>, OtpError> {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use std::{
|
|||||||
|
|
||||||
use ironstorage::{
|
use ironstorage::{
|
||||||
crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError},
|
crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError},
|
||||||
|
document::EntryDocumentService,
|
||||||
mobile_totp::{
|
mobile_totp::{
|
||||||
MobileTotpDiscoveryPhase, MobileTotpError, MobileTotpOperation, MobileTotpService,
|
MobileTotpDiscoveryPhase, MobileTotpError, MobileTotpOperation, MobileTotpService,
|
||||||
MobileWatchSnapshotState,
|
MobileWatchSnapshotState,
|
||||||
@@ -305,6 +306,72 @@ fn totp_cache_reuses_ciphertext_hashes_and_removes_deleted_entries() -> TestResu
|
|||||||
Ok(())
|
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]
|
#[test]
|
||||||
fn cancelled_discovery_checkpoints_completed_entries() -> TestResult {
|
fn cancelled_discovery_checkpoints_completed_entries() -> TestResult {
|
||||||
let fixture = FixtureSet::load()?;
|
let fixture = FixtureSet::load()?;
|
||||||
|
|||||||
Reference in New Issue
Block a user