diff --git a/apple/Generated/ironstorage_apple.swift b/apple/Generated/ironstorage_apple.swift index 0d1fc57..0a33396 100644 --- a/apple/Generated/ironstorage_apple.swift +++ b/apple/Generated/ironstorage_apple.swift @@ -609,6 +609,8 @@ public protocol MobileAuthenticationProtocol: AnyObject, Sendable { func beginEntryEditor(path: String) throws -> MobileEntryEditorSession + func cachedTotpPage() throws -> MobileTotpPage? + func cancel() throws func copyEntryField(path: String, field: UInt64) throws -> MobileEntryCopy @@ -647,7 +649,7 @@ public protocol MobileAuthenticationProtocol: AnyObject, Sendable { func totpDetail(path: String, unixSeconds: UInt64) throws -> MobileTotpDetail - func totpPage() throws -> MobileTotpPage + func totpPage(operation: MobileTotpOperation) throws -> MobileTotpPage func touchUserActivity() throws @@ -746,6 +748,15 @@ open func beginEntryEditor(path: String)throws -> MobileEntryEditorSession { }) } +open func cachedTotpPage()throws -> MobileTotpPage? { + return try FfiConverterOptionTypeMobileTotpPage.lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobileauthentication_cached_totp_page( + self.uniffiCloneHandle(),uniffiCallStatus + ) +}) +} + open func cancel()throws {try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { uniffiCallStatus in uniffi_ironstorage_apple_fn_method_mobileauthentication_cancel( @@ -945,11 +956,12 @@ open func totpDetail(path: String, unixSeconds: UInt64)throws -> MobileTotpDeta }) } -open func totpPage()throws -> MobileTotpPage { +open func totpPage(operation: MobileTotpOperation)throws -> MobileTotpPage { return try FfiConverterTypeMobileTotpPage_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { uniffiCallStatus in uniffi_ironstorage_apple_fn_method_mobileauthentication_totp_page( - self.uniffiCloneHandle(),uniffiCallStatus + self.uniffiCloneHandle(), + FfiConverterTypeMobileTotpOperation_lower(operation),uniffiCallStatus ) }) } @@ -1637,6 +1649,133 @@ public func FfiConverterTypeMobileOnboardingOperation_lower(_ value: MobileOnboa + + +public protocol MobileTotpOperationProtocol: AnyObject, Sendable { + + func cancel() + + func progress() -> MobileTotpDiscoveryProgress + +} +open class MobileTotpOperation: MobileTotpOperationProtocol, @unchecked Sendable { + fileprivate let handle: UInt64 + + /// Used to instantiate a [FFIObject] without an actual handle, for fakes in tests, mostly. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public struct NoHandle { + public init() {} + } + + // TODO: We'd like this to be `private` but for Swifty reasons, + // we can't implement `FfiConverter` without making this `required` and we can't + // make it `required` without making it `public`. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + required public init(unsafeFromHandle handle: UInt64) { + self.handle = handle + } + + // This constructor can be used to instantiate a fake object. + // - Parameter noHandle: Placeholder value so we can have a constructor separate from the default empty one that may be implemented for classes extending [FFIObject]. + // + // - Warning: + // Any object instantiated with this constructor cannot be passed to an actual Rust-backed object. Since there isn't a backing handle the FFI lower functions will crash. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public init(noHandle: NoHandle) { + self.handle = 0 + } + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public func uniffiCloneHandle() -> UInt64 { + return try! rustCall { uniffi_ironstorage_apple_fn_clone_mobiletotpoperation(self.handle, $0) } + } + // No primary constructor declared for this class. + + deinit { + if handle == 0 { + // Mock objects have handle=0 don't try to free them + return + } + + try! rustCall { uniffi_ironstorage_apple_fn_free_mobiletotpoperation(handle, $0) } + } + + + + +open func cancel() {try! rustCall() { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobiletotpoperation_cancel( + self.uniffiCloneHandle(),uniffiCallStatus + ) +} +} + +open func progress() -> MobileTotpDiscoveryProgress { + return try! FfiConverterTypeMobileTotpDiscoveryProgress_lift(try! rustCall() { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobiletotpoperation_progress( + self.uniffiCloneHandle(),uniffiCallStatus + ) +}) +} + + + +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileTotpOperation: FfiConverter { + typealias FfiType = UInt64 + typealias SwiftType = MobileTotpOperation + + public static func lift(_ handle: UInt64) throws -> MobileTotpOperation { + return MobileTotpOperation(unsafeFromHandle: handle) + } + + public static func lower(_ value: MobileTotpOperation) -> UInt64 { + return value.uniffiCloneHandle() + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileTotpOperation { + let handle: UInt64 = try readInt(&buf) + return try lift(handle) + } + + public static func write(_ value: MobileTotpOperation, into buf: inout [UInt8]) { + writeInt(&buf, lower(value)) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileTotpOperation_lift(_ handle: UInt64) throws -> MobileTotpOperation { + return try FfiConverterTypeMobileTotpOperation.lift(handle) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileTotpOperation_lower(_ value: MobileTotpOperation) -> UInt64 { + return FfiConverterTypeMobileTotpOperation.lower(value) +} + + + + public struct MobileAuthenticationState: Equatable, Hashable { public var unlocked: Bool public var biometricUnlockEnabled: Bool @@ -3713,16 +3852,88 @@ public func FfiConverterTypeMobileTotpDetail_lower(_ value: MobileTotpDetail) -> } +public struct MobileTotpDiscoveryProgress: Equatable, Hashable { + public var phase: MobileTotpDiscoveryPhase + public var total: UInt32 + public var inspected: UInt32 + public var cacheHits: UInt32 + public var matches: UInt32 + public var unavailable: UInt32 + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(phase: MobileTotpDiscoveryPhase, total: UInt32, inspected: UInt32, cacheHits: UInt32, matches: UInt32, unavailable: UInt32) { + self.phase = phase + self.total = total + self.inspected = inspected + self.cacheHits = cacheHits + self.matches = matches + self.unavailable = unavailable + } + + + + +} + +#if compiler(>=6) +extension MobileTotpDiscoveryProgress: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileTotpDiscoveryProgress: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileTotpDiscoveryProgress { + return + try MobileTotpDiscoveryProgress( + phase: FfiConverterTypeMobileTotpDiscoveryPhase.read(from: &buf), + total: FfiConverterUInt32.read(from: &buf), + inspected: FfiConverterUInt32.read(from: &buf), + cacheHits: FfiConverterUInt32.read(from: &buf), + matches: FfiConverterUInt32.read(from: &buf), + unavailable: FfiConverterUInt32.read(from: &buf) + ) + } + + public static func write(_ value: MobileTotpDiscoveryProgress, into buf: inout [UInt8]) { + FfiConverterTypeMobileTotpDiscoveryPhase.write(value.phase, into: &buf) + FfiConverterUInt32.write(value.total, into: &buf) + FfiConverterUInt32.write(value.inspected, into: &buf) + FfiConverterUInt32.write(value.cacheHits, into: &buf) + FfiConverterUInt32.write(value.matches, into: &buf) + FfiConverterUInt32.write(value.unavailable, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileTotpDiscoveryProgress_lift(_ buf: RustBuffer) throws -> MobileTotpDiscoveryProgress { + return try FfiConverterTypeMobileTotpDiscoveryProgress.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileTotpDiscoveryProgress_lower(_ value: MobileTotpDiscoveryProgress) -> RustBuffer { + return FfiConverterTypeMobileTotpDiscoveryProgress.lower(value) +} + + public struct MobileTotpPage: Equatable, Hashable { public var rows: [MobileTotpRow] public var unavailableEntries: UInt32 + public var cacheNotice: String? public var watch: MobileWatchSnapshotStatus // Default memberwise initializers are never public by default, so we // declare one manually. - public init(rows: [MobileTotpRow], unavailableEntries: UInt32, watch: MobileWatchSnapshotStatus) { + public init(rows: [MobileTotpRow], unavailableEntries: UInt32, cacheNotice: String?, watch: MobileWatchSnapshotStatus) { self.rows = rows self.unavailableEntries = unavailableEntries + self.cacheNotice = cacheNotice self.watch = watch } @@ -3744,6 +3955,7 @@ public struct FfiConverterTypeMobileTotpPage: FfiConverterRustBuffer { try MobileTotpPage( rows: FfiConverterSequenceTypeMobileTotpRow.read(from: &buf), unavailableEntries: FfiConverterUInt32.read(from: &buf), + cacheNotice: FfiConverterOptionString.read(from: &buf), watch: FfiConverterTypeMobileWatchSnapshotStatus.read(from: &buf) ) } @@ -3751,6 +3963,7 @@ public struct FfiConverterTypeMobileTotpPage: FfiConverterRustBuffer { public static func write(_ value: MobileTotpPage, into buf: inout [UInt8]) { FfiConverterSequenceTypeMobileTotpRow.write(value.rows, into: &buf) FfiConverterUInt32.write(value.unavailableEntries, into: &buf) + FfiConverterOptionString.write(value.cacheNotice, into: &buf) FfiConverterTypeMobileWatchSnapshotStatus.write(value.watch, into: &buf) } } @@ -5747,6 +5960,93 @@ public func FfiConverterTypeMobileTab_lower(_ value: MobileTab) -> RustBuffer { +public enum MobileTotpDiscoveryPhase: Equatable, Hashable { + + case preparing + case inspecting + case saving + case complete + case cancelled + + + + + +} + +#if compiler(>=6) +extension MobileTotpDiscoveryPhase: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileTotpDiscoveryPhase: FfiConverterRustBuffer { + typealias SwiftType = MobileTotpDiscoveryPhase + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileTotpDiscoveryPhase { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .preparing + + case 2: return .inspecting + + case 3: return .saving + + case 4: return .complete + + case 5: return .cancelled + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: MobileTotpDiscoveryPhase, into buf: inout [UInt8]) { + switch value { + + + case .preparing: + writeInt(&buf, Int32(1)) + + + case .inspecting: + writeInt(&buf, Int32(2)) + + + case .saving: + writeInt(&buf, Int32(3)) + + + case .complete: + writeInt(&buf, Int32(4)) + + + case .cancelled: + writeInt(&buf, Int32(5)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileTotpDiscoveryPhase_lift(_ buf: RustBuffer) throws -> MobileTotpDiscoveryPhase { + return try FfiConverterTypeMobileTotpDiscoveryPhase.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileTotpDiscoveryPhase_lower(_ value: MobileTotpDiscoveryPhase) -> RustBuffer { + return FfiConverterTypeMobileTotpDiscoveryPhase.lower(value) +} + + + + public enum MobileWatchSnapshotState: Equatable, Hashable { case unavailable @@ -5931,6 +6231,30 @@ fileprivate struct FfiConverterOptionTypeMobileKeyTransferKey: FfiConverterRustB } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionTypeMobileTotpPage: FfiConverterRustBuffer { + typealias SwiftType = MobileTotpPage? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterTypeMobileTotpPage.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 FfiConverterTypeMobileTotpPage.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -6343,6 +6667,13 @@ public func mobileShellFixture(state: MobileShellState) -> MobileShell { ) }) } +public func mobileTotpOperation() -> MobileTotpOperation { + return try! FfiConverterTypeMobileTotpOperation_lift(try! rustCall() { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_func_mobile_totp_operation(uniffiCallStatus + ) +}) +} public func productName() -> String { return try! FfiConverterString.lift(try! rustCall() { uniffiCallStatus in @@ -6405,6 +6736,9 @@ private let initializationResult: InitializationResult = { if (uniffi_ironstorage_apple_checksum_func_mobile_shell_fixture() != 1649) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ironstorage_apple_checksum_func_mobile_totp_operation() != 22350) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ironstorage_apple_checksum_func_product_name() != 43533) { return InitializationResult.apiChecksumMismatch } @@ -6423,6 +6757,9 @@ private let initializationResult: InitializationResult = { if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_begin_entry_editor() != 63281) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_cached_totp_page() != 36629) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_cancel() != 6512) { return InitializationResult.apiChecksumMismatch } @@ -6480,7 +6817,7 @@ private let initializationResult: InitializationResult = { if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_totp_detail() != 42762) { return InitializationResult.apiChecksumMismatch } - if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_totp_page() != 18529) { + if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_totp_page() != 15475) { return InitializationResult.apiChecksumMismatch } if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_touch_user_activity() != 18402) { @@ -6540,6 +6877,12 @@ private let initializationResult: InitializationResult = { if (uniffi_ironstorage_apple_checksum_method_mobileonboardingoperation_setup() != 3525) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ironstorage_apple_checksum_method_mobiletotpoperation_cancel() != 30179) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ironstorage_apple_checksum_method_mobiletotpoperation_progress() != 54435) { + return InitializationResult.apiChecksumMismatch + } return InitializationResult.ok }() diff --git a/apple/Generated/ironstorage_appleFFI.h b/apple/Generated/ironstorage_appleFFI.h index c5e2035..7c93f8d 100644 --- a/apple/Generated/ironstorage_appleFFI.h +++ b/apple/Generated/ironstorage_appleFFI.h @@ -268,6 +268,11 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_begin_create_ RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_begin_entry_editor(uint64_t ptr, RustBuffer path, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_CACHED_TOTP_PAGE +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_CACHED_TOTP_PAGE +RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_cached_totp_page(uint64_t ptr, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_CANCEL #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_CANCEL void uniffi_ironstorage_apple_fn_method_mobileauthentication_cancel(uint64_t ptr, RustCallStatus *_Nonnull out_status @@ -365,7 +370,7 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_totp_detail(u #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_TOTP_PAGE #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_TOTP_PAGE -RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_totp_page(uint64_t ptr, RustCallStatus *_Nonnull out_status +RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_totp_page(uint64_t ptr, uint64_t operation, RustCallStatus *_Nonnull out_status ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_TOUCH_USER_ACTIVITY @@ -503,6 +508,26 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobileonboardingoperation_progress RustBuffer uniffi_ironstorage_apple_fn_method_mobileonboardingoperation_setup(uint64_t ptr, RustBuffer branch, int8_t use_existing, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILETOTPOPERATION +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILETOTPOPERATION +uint64_t uniffi_ironstorage_apple_fn_clone_mobiletotpoperation(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FREE_MOBILETOTPOPERATION +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FREE_MOBILETOTPOPERATION +void uniffi_ironstorage_apple_fn_free_mobiletotpoperation(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILETOTPOPERATION_CANCEL +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILETOTPOPERATION_CANCEL +void uniffi_ironstorage_apple_fn_method_mobiletotpoperation_cancel(uint64_t ptr, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILETOTPOPERATION_PROGRESS +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILETOTPOPERATION_PROGRESS +RustBuffer uniffi_ironstorage_apple_fn_method_mobiletotpoperation_progress(uint64_t ptr, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_AUTHENTICATION #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_AUTHENTICATION uint64_t uniffi_ironstorage_apple_fn_func_mobile_authentication(RustCallStatus *_Nonnull out_status @@ -545,6 +570,12 @@ RustBuffer uniffi_ironstorage_apple_fn_func_mobile_shell(RustCallStatus *_Nonnul #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_SHELL_FIXTURE #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_SHELL_FIXTURE RustBuffer uniffi_ironstorage_apple_fn_func_mobile_shell_fixture(RustBuffer state, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_TOTP_OPERATION +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_TOTP_OPERATION +uint64_t uniffi_ironstorage_apple_fn_func_mobile_totp_operation(RustCallStatus *_Nonnull out_status + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_PRODUCT_NAME @@ -869,6 +900,12 @@ uint16_t uniffi_ironstorage_apple_checksum_func_mobile_shell(void #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_SHELL_FIXTURE uint16_t uniffi_ironstorage_apple_checksum_func_mobile_shell_fixture(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_TOTP_OPERATION +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_TOTP_OPERATION +uint16_t uniffi_ironstorage_apple_checksum_func_mobile_totp_operation(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_PRODUCT_NAME @@ -905,6 +942,12 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_begin_cre #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_BEGIN_ENTRY_EDITOR uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_begin_entry_editor(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_CACHED_TOTP_PAGE +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_CACHED_TOTP_PAGE +uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_cached_totp_page(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_CANCEL @@ -1139,6 +1182,18 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobileonboardingoperation_prog #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEONBOARDINGOPERATION_SETUP uint16_t uniffi_ironstorage_apple_checksum_method_mobileonboardingoperation_setup(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILETOTPOPERATION_CANCEL +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILETOTPOPERATION_CANCEL +uint16_t uniffi_ironstorage_apple_checksum_method_mobiletotpoperation_cancel(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILETOTPOPERATION_PROGRESS +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILETOTPOPERATION_PROGRESS +uint16_t uniffi_ironstorage_apple_checksum_method_mobiletotpoperation_progress(void + ); #endif #ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_APPLE_UNIFFI_CONTRACT_VERSION diff --git a/apple/Sources/App/IronStorageApp.swift b/apple/Sources/App/IronStorageApp.swift index c052b94..6295643 100644 --- a/apple/Sources/App/IronStorageApp.swift +++ b/apple/Sources/App/IronStorageApp.swift @@ -654,7 +654,11 @@ private final class ShellViewController: UITableViewController, MobileTabRoot { ) if failure.kind == .authentication || failure.kind == .secureStorage { alert.addAction(UIAlertAction(title: "Preferences", style: .default) { [weak self] _ in - self?.tabBarController?.selectedIndex = 3 + guard let tabs = self?.tabBarController else { return } + tabs.selectedIndex = tabs.viewControllers?.firstIndex { controller in + guard let navigation = controller as? UINavigationController else { return false } + return (navigation.viewControllers.first as? MobileTabRoot)?.shellTab == .preferences + } ?? tabs.selectedIndex }) } alert.addAction(UIAlertAction(title: "OK", style: .cancel)) @@ -1577,6 +1581,76 @@ private final class KeyQrView: UIView { } } +@MainActor +private final class TotpDiscoveryView: UIView { + private let spinner = UIActivityIndicatorView(style: .medium) + private let titleLabel = UILabel() + private let detailLabel = UILabel() + private let progressView = UIProgressView(progressViewStyle: .default) + + init(cancel: @escaping () -> Void) { + super.init(frame: .zero) + titleLabel.font = .preferredFont(forTextStyle: .headline) + titleLabel.adjustsFontForContentSizeCategory = true + titleLabel.textAlignment = .center + detailLabel.font = .preferredFont(forTextStyle: .subheadline) + detailLabel.adjustsFontForContentSizeCategory = true + detailLabel.textColor = .secondaryLabel + detailLabel.textAlignment = .center + detailLabel.numberOfLines = 0 + progressView.accessibilityLabel = "TOTP discovery progress" + let cancelButton = UIButton(type: .system, primaryAction: UIAction(title: "Cancel") { _ in + cancel() + }) + let progressRow = UIStackView(arrangedSubviews: [spinner, progressView]) + progressRow.alignment = .center + progressRow.spacing = 12 + let stack = UIStackView(arrangedSubviews: [titleLabel, detailLabel, progressRow, cancelButton]) + stack.axis = .vertical + stack.alignment = .fill + stack.spacing = 12 + stack.translatesAutoresizingMaskIntoConstraints = false + addSubview(stack) + NSLayoutConstraint.activate([ + stack.centerXAnchor.constraint(equalTo: centerXAnchor), + stack.topAnchor.constraint(equalTo: topAnchor, constant: 24), + stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -24), + stack.leadingAnchor.constraint(greaterThanOrEqualTo: readableContentGuide.leadingAnchor), + stack.trailingAnchor.constraint(lessThanOrEqualTo: readableContentGuide.trailingAnchor), + progressView.widthAnchor.constraint(greaterThanOrEqualToConstant: 180), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } + + func apply(_ progress: MobileTotpDiscoveryProgress) { + titleLabel.text = switch progress.phase { + case .preparing: "Preparing TOTP Discovery" + case .inspecting: "Discovering TOTP Entries" + case .saving: "Saving Protected Cache" + case .complete: "TOTP Discovery Complete" + case .cancelled: "Cancelling TOTP Discovery" + } + if progress.total == 0 { + spinner.startAnimating() + progressView.isHidden = true + detailLabel.text = "Reading the password-store inventory." + } else { + spinner.stopAnimating() + progressView.isHidden = false + progressView.progress = Float(progress.inspected) / Float(progress.total) + progressView.accessibilityValue = "\(progress.inspected) of \(progress.total)" + detailLabel.text = + "\(progress.inspected) of \(progress.total) inspected • " + + "\(progress.cacheHits) cached • \(progress.matches) TOTP" + + (progress.unavailable == 0 ? "" : " • \(progress.unavailable) unavailable") + } + } +} + @MainActor private final class TotpListViewController: UITableViewController, MobileTabRoot { fileprivate let shellTab = MobileTab.totp @@ -1585,6 +1659,11 @@ private final class TotpListViewController: UITableViewController, MobileTabRoot private var page: MobileTotpPage? private var loadTask: Task? private var unlockTask: Task? + private var shellTask: Task? + private var progressTask: Task? + private var operation: MobileTotpOperation? + private var loadGeneration = 0 + private var refreshAfterUnlock = false init(shellPage: MobilePage, authentication: MobileAuthentication?) { self.shellPage = shellPage @@ -1602,8 +1681,11 @@ private final class TotpListViewController: UITableViewController, MobileTabRoot } deinit { + operation?.cancel() loadTask?.cancel() unlockTask?.cancel() + shellTask?.cancel() + progressTask?.cancel() NotificationCenter.default.removeObserver(self) } @@ -1632,7 +1714,7 @@ private final class TotpListViewController: UITableViewController, MobileTabRoot override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - refreshState() + reloadShell() } override func numberOfSections(in tableView: UITableView) -> Int { 1 } @@ -1649,7 +1731,8 @@ private final class TotpListViewController: UITableViewController, MobileTabRoot let unavailable = page.unavailableEntries == 0 ? "" : " \(page.unavailableEntries) entries could not be inspected with the active key." - return page.watch.detail + unavailable + let cache = page.cacheNotice.map { " \($0)" } ?? "" + return page.watch.detail + unavailable + cache } override func tableView( @@ -1677,7 +1760,16 @@ private final class TotpListViewController: UITableViewController, MobileTabRoot override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) - guard let row = page?.rows[indexPath.row], let authentication else { return } + guard let row = page?.rows[indexPath.row] else { return } + guard (try? authentication?.state().unlocked) == true else { + unlock(passphrase: nil, row: row) + return + } + open(row) + } + + private func open(_ row: MobileTotpRow) { + guard let authentication else { return } loadTask?.cancel() loadTask = Task { [weak self] in let result = await Task.detached(priority: .userInitiated) { @@ -1709,20 +1801,36 @@ private final class TotpListViewController: UITableViewController, MobileTabRoot } @objc private func refreshRequested() { - refreshState(force: true) + if (try? authentication?.state().unlocked) == true { + loadPage() + } else { + refreshAfterUnlock = true + refreshControl?.endRefreshing() + unlockRequested() + } } @objc private func authenticationDidChange() { + if (try? authentication?.state().unlocked) != true { + operation?.cancel() + } refreshState() } @objc private func localStoreDidChange() { - guard (try? authentication?.state().unlocked) == true else { return } - loadPage() + operation?.cancel() + loadTask?.cancel() + progressTask?.cancel() + loadGeneration += 1 + operation = nil + progressTask = nil + tableView.tableHeaderView = nil + page = nil + loadCachedPage() } @objc private func unlockRequested() { - unlock(passphrase: nil) + unlock(passphrase: nil, row: nil) } @objc private func lockRequested() { @@ -1750,29 +1858,30 @@ private final class TotpListViewController: UITableViewController, MobileTabRoot ) return } - if (try? authentication?.state().unlocked) == true { - if page == nil || force { loadPage() } - } else { - loadTask?.cancel() - page = nil - tableView.reloadData() - navigationItem.rightBarButtonItem = nil - var configuration = UIContentUnavailableConfiguration.empty() - configuration.image = UIImage(systemName: "lock.fill") - configuration.text = "TOTP Is Locked" - configuration.secondaryText = - "Authenticate to scan password entries for time-based one-time passwords." - configuration.button = .filled() - configuration.button.title = "Unlock" - configuration.buttonProperties.primaryAction = UIAction { [weak self] _ in - self?.unlockRequested() - } - contentUnavailableConfiguration = configuration - refreshControl?.endRefreshing() + if force, (try? authentication?.state().unlocked) == true { + loadPage() + } else if page == nil, loadTask == nil { + loadCachedPage() + } else if page != nil { + configureLockButton() } } - private func unlock(passphrase: String?) { + private func reloadShell() { + shellTask?.cancel() + shellTask = Task { [weak self] in + let shell = await Task.detached(priority: .userInitiated) { mobileShell() }.value + guard + !Task.isCancelled, + let self, + let page = shell.pages.first(where: { $0.tab == .totp }) + else { return } + shellPage = page + refreshState() + } + } + + private func unlock(passphrase: String?, row: MobileTotpRow?) { guard let authentication else { presentAuthenticationFailure(.unavailable) return @@ -1782,8 +1891,13 @@ private final class TotpListViewController: UITableViewController, MobileTabRoot unlockTask = Task { [weak self] in let result = await Task.detached(priority: .userInitiated) { do { - return Result.success( + let state = if let row { + try authentication.unlockEntry(path: row.path, passphrase: passphrase) + } else { try authentication.unlockTotp(passphrase: passphrase) + } + return Result.success( + state ) } catch let error as MobileAuthenticationFfiError { return .failure(AuthenticationFailure(error)) @@ -1792,26 +1906,34 @@ private final class TotpListViewController: UITableViewController, MobileTabRoot } }.value guard !Task.isCancelled, let self else { return } + contentUnavailableConfiguration = nil switch result { case .success: NotificationCenter.default.post( name: .ironStorageAuthenticationDidChange, object: authentication ) - loadPage() + if refreshAfterUnlock || page == nil { + refreshAfterUnlock = false + loadPage() + } else if let row { + open(row) + } else { + configureLockButton() + } UIAccessibility.post(notification: .announcement, argument: "TOTP unlocked") case let .failure(failure) where passphrase == nil && (failure.kind == .passphraseRequired || failure.kind == .biometryUnavailable): - promptForPassphrase(message: failure.detail) + promptForPassphrase(message: failure.detail, row: row) case let .failure(failure): if failure.kind != .cancelled { handle(failure) } } } } - private func promptForPassphrase(message: String) { + private func promptForPassphrase(message: String, row: MobileTotpRow?) { let alert = UIAlertController( title: "GPG Key Passphrase", message: message, @@ -1829,7 +1951,7 @@ private final class TotpListViewController: UITableViewController, MobileTabRoot alert.addAction(UIAlertAction(title: "Unlock", style: .default) { [weak self, weak alert] _ in guard let value = alert?.textFields?.first?.text, !value.isEmpty else { return } alert?.textFields?.first?.text = nil - self?.unlock(passphrase: value) + self?.unlock(passphrase: value, row: row) }) present(alert, animated: true) } @@ -1839,13 +1961,75 @@ private final class TotpListViewController: UITableViewController, MobileTabRoot presentAuthenticationFailure(.unavailable) return } + operation?.cancel() loadTask?.cancel() - showLoading("Loading TOTP") + progressTask?.cancel() + loadGeneration += 1 + let current = loadGeneration + let operation = mobileTotpOperation() + self.operation = operation + navigationItem.rightBarButtonItems = nil + let discoveryView = TotpDiscoveryView { [weak self] in + self?.cancelDiscovery() + } + apply(operation.progress(), to: discoveryView) + tableView.tableHeaderView = discoveryView + contentUnavailableConfiguration = nil + progressTask = Task { [weak self] in + while !Task.isCancelled { + do { + try await Task.sleep(for: .milliseconds(150)) + } catch { + return + } + guard !Task.isCancelled, let self, current == loadGeneration else { return } + apply(operation.progress(), to: discoveryView) + } + } loadTask = Task { [weak self] in let result = await Task.detached(priority: .userInitiated) { do { return Result.success( - try authentication.totpPage() + try authentication.totpPage(operation: operation) + ) + } catch let error as MobileAuthenticationFfiError { + return .failure(AuthenticationFailure(error)) + } catch { + return .failure(.unexpected) + } + }.value + guard !Task.isCancelled, let self, current == loadGeneration else { return } + progressTask?.cancel() + progressTask = nil + self.operation = nil + loadTask = nil + tableView.tableHeaderView = nil + refreshControl?.endRefreshing() + configureLockButton() + switch result { + case let .success(page): + apply(page) + case let .failure(failure): + if failure.kind == .cancelled { + loadCachedPage() + } else { + handle(failure) + } + } + } + } + + private func loadCachedPage() { + guard let authentication else { + presentAuthenticationFailure(.unavailable) + return + } + loadTask?.cancel() + loadTask = Task { [weak self] in + let result = await Task.detached(priority: .userInitiated) { + do { + return Result.success( + try authentication.cachedTotpPage() ) } catch let error as MobileAuthenticationFfiError { return .failure(AuthenticationFailure(error)) @@ -1854,26 +2038,15 @@ private final class TotpListViewController: UITableViewController, MobileTabRoot } }.value guard !Task.isCancelled, let self else { return } - refreshControl?.endRefreshing() + loadTask = nil switch result { - case let .success(page): - self.page = page - navigationItem.rightBarButtonItem = UIBarButtonItem( - image: UIImage(systemName: "lock.fill"), - style: .plain, - target: self, - action: #selector(lockRequested) - ) - navigationItem.rightBarButtonItem?.accessibilityLabel = "Lock IronStorage" - tableView.reloadData() - if page.rows.isEmpty { - showUnavailable( - title: "No TOTP Codes", - detail: "No valid time-based OTP entries were found in the password store.", - image: "timer" - ) + case let .success(.some(page)): + apply(page) + case .success(.none): + if (try? authentication.state().unlocked) == true { + loadPage() } else { - contentUnavailableConfiguration = nil + showLocked() } case let .failure(failure): handle(failure) @@ -1881,6 +2054,69 @@ private final class TotpListViewController: UITableViewController, MobileTabRoot } } + private func apply(_ page: MobileTotpPage) { + self.page = page + configureLockButton() + tableView.reloadData() + if page.rows.isEmpty { + showUnavailable( + title: "No TOTP Codes", + detail: "No valid time-based OTP entries were found in the password store.", + image: "timer" + ) + } else { + contentUnavailableConfiguration = nil + } + } + + private func configureLockButton() { + let unlocked = (try? authentication?.state().unlocked) == true + navigationItem.rightBarButtonItem = UIBarButtonItem( + image: UIImage(systemName: unlocked ? "lock.fill" : "lock.open.fill"), + style: .plain, + target: self, + action: unlocked ? #selector(lockRequested) : #selector(unlockRequested) + ) + navigationItem.rightBarButtonItem?.accessibilityLabel = + unlocked ? "Lock IronStorage" : "Unlock IronStorage" + } + + private func showLocked() { + page = nil + tableView.reloadData() + navigationItem.rightBarButtonItem = nil + var configuration = UIContentUnavailableConfiguration.empty() + configuration.image = UIImage(systemName: "lock.fill") + configuration.text = "TOTP Is Locked" + configuration.secondaryText = + "Unlock once to discover time-based one-time-password entries." + configuration.button = .filled() + configuration.button.title = "Unlock" + configuration.buttonProperties.primaryAction = UIAction { [weak self] _ in + self?.unlockRequested() + } + contentUnavailableConfiguration = configuration + refreshControl?.endRefreshing() + } + + private func cancelDiscovery() { + operation?.cancel() + } + + private func apply(_ progress: MobileTotpDiscoveryProgress, to view: TotpDiscoveryView) { + view.apply(progress) + let width = tableView.bounds.width + let height = view.systemLayoutSizeFitting( + CGSize(width: width, height: UIView.layoutFittingCompressedSize.height), + withHorizontalFittingPriority: .required, + verticalFittingPriority: .fittingSizeLevel + ).height + view.frame = CGRect(x: 0, y: 0, width: width, height: height) + if tableView.tableHeaderView === view { + tableView.tableHeaderView = view + } + } + private func handle(_ failure: AuthenticationFailure) { if failure.kind == .expired { NotificationCenter.default.post( @@ -1895,6 +2131,7 @@ private final class TotpListViewController: UITableViewController, MobileTabRoot } private func showLoading(_ title: String) { + tableView.backgroundView = nil var configuration = UIContentUnavailableConfiguration.loading() configuration.text = title configuration.secondaryText = "Reading OTP metadata in secure storage." @@ -1902,6 +2139,7 @@ private final class TotpListViewController: UITableViewController, MobileTabRoot } private func showUnavailable(title: String, detail: String, image: String) { + tableView.backgroundView = nil var configuration = UIContentUnavailableConfiguration.empty() configuration.image = UIImage(systemName: image) configuration.text = title diff --git a/crates/apple/src/lib.rs b/crates/apple/src/lib.rs index cb61b69..f258b87 100644 --- a/crates/apple/src/lib.rs +++ b/crates/apple/src/lib.rs @@ -53,7 +53,9 @@ use ironstorage::{ MobilePasswordRowKind as StoragePasswordRowKind, }, mobile_totp::{ - MobileTotpDetail as StorageTotpDetail, MobileTotpPage as StorageTotpPage, + MobileTotpDetail as StorageTotpDetail, + MobileTotpDiscoveryPhase as StorageTotpDiscoveryPhase, + MobileTotpOperation as StorageTotpOperation, MobileTotpPage as StorageTotpPage, MobileWatchSnapshotState as StorageWatchSnapshotState, MobileWatchSnapshotStatus as StorageWatchSnapshotStatus, }, @@ -812,6 +814,7 @@ pub struct MobileTotpRow { pub struct MobileTotpPage { pub rows: Vec, pub unavailable_entries: u32, + pub cache_notice: Option, pub watch: MobileWatchSnapshotStatus, } @@ -831,11 +834,67 @@ impl From for MobileTotpPage { }) .collect(), unavailable_entries: page.unavailable_entries(), + cache_notice: page.cache_notice().map(str::to_owned), watch: page.watch().into(), } } } +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum MobileTotpDiscoveryPhase { + Preparing, + Inspecting, + Saving, + Complete, + Cancelled, +} + +impl From for MobileTotpDiscoveryPhase { + fn from(phase: StorageTotpDiscoveryPhase) -> Self { + match phase { + StorageTotpDiscoveryPhase::Preparing => Self::Preparing, + StorageTotpDiscoveryPhase::Inspecting => Self::Inspecting, + StorageTotpDiscoveryPhase::Saving => Self::Saving, + StorageTotpDiscoveryPhase::Complete => Self::Complete, + StorageTotpDiscoveryPhase::Cancelled => Self::Cancelled, + } + } +} + +#[derive(Clone, uniffi::Record)] +pub struct MobileTotpDiscoveryProgress { + pub phase: MobileTotpDiscoveryPhase, + pub total: u32, + pub inspected: u32, + pub cache_hits: u32, + pub matches: u32, + pub unavailable: u32, +} + +#[derive(uniffi::Object)] +pub struct MobileTotpOperation { + operation: StorageTotpOperation, +} + +#[uniffi::export] +impl MobileTotpOperation { + pub fn progress(&self) -> MobileTotpDiscoveryProgress { + let progress = self.operation.progress(); + MobileTotpDiscoveryProgress { + phase: progress.phase().into(), + total: progress.total(), + inspected: progress.inspected(), + cache_hits: progress.cache_hits(), + matches: progress.matches(), + unavailable: progress.unavailable(), + } + } + + pub fn cancel(&self) { + self.operation.cancel(); + } +} + #[derive(Clone, uniffi::Record)] pub struct MobileTotpDetail { pub path: String, @@ -1369,13 +1428,23 @@ impl MobileAuthentication { .map_err(Into::into) } - pub fn totp_page(&self) -> Result { + pub fn totp_page( + &self, + operation: Arc, + ) -> Result { self.authentication - .totp_page() + .totp_page(&operation.operation) .map(Into::into) .map_err(Into::into) } + pub fn cached_totp_page(&self) -> Result, MobileAuthenticationFfiError> { + self.authentication + .cached_totp_page() + .map(|page| page.map(Into::into)) + .map_err(Into::into) + } + pub fn totp_detail( &self, path: String, @@ -1733,6 +1802,13 @@ pub fn mobile_home_operation() -> Arc { }) } +#[uniffi::export] +pub fn mobile_totp_operation() -> Arc { + Arc::new(MobileTotpOperation { + operation: StorageTotpOperation::default(), + }) +} + #[uniffi::export] pub fn mobile_password_page( path: Option, diff --git a/crates/storage/src/mobile_authentication.rs b/crates/storage/src/mobile_authentication.rs index bb5dd33..ce6eae8 100644 --- a/crates/storage/src/mobile_authentication.rs +++ b/crates/storage/src/mobile_authentication.rs @@ -19,7 +19,10 @@ use crate::{ MobileMutationAction, MobileMutationError, MobileMutationOutcome, MobileMutationPlan, MobileMutationRequest, MobileMutationService, }, - mobile_totp::{MobileTotpDetail, MobileTotpError, MobileTotpPage, MobileTotpService}, + mobile_totp::{ + MobileTotpDetail, MobileTotpError, MobileTotpOperation, MobileTotpPage, MobileTotpService, + }, + otp::OtpError, recipient::RecipientPolicyManager, repository::{ DirectoryPath, EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes, @@ -177,6 +180,7 @@ pub struct MobileAuthentication { keys: KeyStore, session: NativeAuthenticationSession, status: Mutex, + totp_discovery: Mutex<()>, } impl MobileAuthentication { @@ -208,6 +212,7 @@ impl MobileAuthentication { repository, keys, session, + totp_discovery: Mutex::new(()), }) } @@ -481,8 +486,17 @@ impl MobileAuthentication { self.unlock_ciphertext(&ciphertext, passphrase) } - pub fn totp_page(&self) -> Result { + pub fn totp_page( + &self, + operation: &MobileTotpOperation, + ) -> Result { self.ensure_active()?; + let _discovery = self.totp_discovery.lock().map_err(|_| { + entry_detail( + "TOTP Discovery Is Unavailable", + "the TOTP discovery lock is unavailable", + ) + })?; let (handle, key, shared) = { let status = self.status()?; let active = status.active.as_ref().ok_or_else(locked_error)?; @@ -492,12 +506,19 @@ impl MobileAuthentication { status.watch_shared_totp_entries.clone(), ) }; + let cache_path = self.config.source().with_file_name("totp-catalog.toml"); let mut provider = KeyOnlyProvider::new(handle, &key); MobileTotpService::new(&self.repository, &self.keys) - .page(&shared, &mut provider) + .discover(&shared, &mut provider, &cache_path, operation) .map_err(totp_error) } + pub fn cached_totp_page(&self) -> Result, MobileAuthenticationError> { + let shared = self.status()?.watch_shared_totp_entries.clone(); + let cache_path = self.config.source().with_file_name("totp-catalog.toml"); + Ok(MobileTotpService::new(&self.repository, &self.keys).cached_page(&shared, &cache_path)) + } + pub fn totp_detail( &self, path: &str, @@ -1043,7 +1064,19 @@ fn editor_error(error: MobileEntryEditorError) -> MobileAuthenticationError { } fn totp_error(error: MobileTotpError) -> MobileAuthenticationError { - entry_detail("TOTP Is Unavailable", error) + match error { + MobileTotpError::Otp(OtpError::Cancelled) => MobileAuthenticationError::new( + MobileAuthenticationErrorKind::Cancelled, + "TOTP Discovery Cancelled", + "TOTP discovery was cancelled", + ), + MobileTotpError::ConcurrentModification => MobileAuthenticationError::new( + MobileAuthenticationErrorKind::Conflict, + "TOTP Discovery Needs Refreshing", + "the password store changed while TOTP discovery was in progress", + ), + error => entry_detail("TOTP Is Unavailable", error), + } } fn editor_missing() -> MobileAuthenticationError { diff --git a/crates/storage/src/mobile_totp.rs b/crates/storage/src/mobile_totp.rs index 06f6eea..c66c34f 100644 --- a/crates/storage/src/mobile_totp.rs +++ b/crates/storage/src/mobile_totp.rs @@ -1,13 +1,126 @@ //! Storage-owned TOTP catalog and detail state for native mobile frontends. -use std::{collections::BTreeSet, error::Error, fmt}; +use std::{ + collections::{BTreeMap, BTreeSet}, + error::Error, + fmt, fs, + io::Write as _, + path::Path, + sync::{ + Mutex, + atomic::{AtomicBool, Ordering}, + }, +}; + +use cap_std::{ambient_authority, fs::Dir}; +use cap_tempfile::TempFile; +use data_encoding::HEXLOWER; +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; use crate::{ crypto::{KeyStore, SecretProvider}, otp::{OtpError, OtpKind, OtpService}, - repository::{EntryPath, Repository, RepositoryError, SecretBytes}, + repository::{EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes}, }; +const CACHE_VERSION: u32 = 1; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MobileTotpDiscoveryPhase { + Preparing, + Inspecting, + Saving, + Complete, + Cancelled, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MobileTotpDiscoveryProgress { + phase: MobileTotpDiscoveryPhase, + total: u32, + inspected: u32, + cache_hits: u32, + matches: u32, + unavailable: u32, +} + +impl MobileTotpDiscoveryProgress { + pub fn phase(&self) -> MobileTotpDiscoveryPhase { + self.phase + } + + pub fn total(&self) -> u32 { + self.total + } + + pub fn inspected(&self) -> u32 { + self.inspected + } + + pub fn cache_hits(&self) -> u32 { + self.cache_hits + } + + pub fn matches(&self) -> u32 { + self.matches + } + + pub fn unavailable(&self) -> u32 { + self.unavailable + } +} + +pub struct MobileTotpOperation { + cancelled: AtomicBool, + progress: Mutex, +} + +impl Default for MobileTotpOperation { + fn default() -> Self { + Self { + cancelled: AtomicBool::new(false), + progress: Mutex::new(MobileTotpDiscoveryProgress { + phase: MobileTotpDiscoveryPhase::Preparing, + total: 0, + inspected: 0, + cache_hits: 0, + matches: 0, + unavailable: 0, + }), + } + } +} + +impl MobileTotpOperation { + pub fn cancel(&self) { + self.cancelled.store(true, Ordering::Relaxed); + self.update(|progress| progress.phase = MobileTotpDiscoveryPhase::Cancelled); + } + + pub fn progress(&self) -> MobileTotpDiscoveryProgress { + self.progress.lock().map_or_else( + |poisoned| poisoned.into_inner().clone(), + |value| value.clone(), + ) + } + + fn check_cancelled(&self) -> Result<(), MobileTotpError> { + if self.cancelled.load(Ordering::Relaxed) { + Err(OtpError::Cancelled.into()) + } else { + Ok(()) + } + } + + fn update(&self, update: impl FnOnce(&mut MobileTotpDiscoveryProgress)) { + match self.progress.lock() { + Ok(mut progress) => update(&mut progress), + Err(poisoned) => update(&mut poisoned.into_inner()), + } + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum MobileWatchSnapshotState { Unavailable, @@ -70,6 +183,7 @@ impl MobileTotpRow { pub struct MobileTotpPage { rows: Vec, unavailable_entries: u32, + cache_notice: Option, watch: MobileWatchSnapshotStatus, } @@ -82,6 +196,10 @@ impl MobileTotpPage { self.unavailable_entries } + pub fn cache_notice(&self) -> Option<&str> { + self.cache_notice.as_deref() + } + pub fn watch(&self) -> &MobileWatchSnapshotStatus { &self.watch } @@ -184,21 +302,129 @@ impl<'a> MobileTotpService<'a> { Err(_) => {} } } - rows.sort_by(|left, right| { - left.title - .to_lowercase() - .cmp(&right.title.to_lowercase()) - .then_with(|| { - left.account - .to_lowercase() - .cmp(&right.account.to_lowercase()) - }) - .then_with(|| left.path.cmp(&right.path)) - }); + sort_rows(&mut rows); let selected = rows.iter().filter(|row| row.shared_with_watch).count(); Ok(MobileTotpPage { rows, unavailable_entries, + cache_notice: None, + watch: snapshot_status(selected), + }) + } + + pub fn discover( + &self, + shared: &BTreeSet, + provider: &mut impl SecretProvider, + cache_path: &Path, + operation: &MobileTotpOperation, + ) -> Result { + operation.check_cancelled()?; + let initial_inventory = inventory(self.repository)?; + operation.update(|progress| { + progress.phase = MobileTotpDiscoveryPhase::Inspecting; + progress.total = u32::try_from(initial_inventory.len()).unwrap_or(u32::MAX); + }); + + 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; + for (path, encrypted, ciphertext_hash) in &initial_inventory { + operation.check_cancelled()?; + let path_text = path.to_string(); + let cached_record = cached + .as_ref() + .and_then(|catalog| catalog.entries.get(&path_text)) + .filter(|record| record.ciphertext_hash == *ciphertext_hash); + let (record, changed) = if let Some(record) = cached_record { + operation + .update(|progress| progress.cache_hits = progress.cache_hits.saturating_add(1)); + (record.clone(), false) + } else { + match inspect_entry(self.repository, self.keys, path, encrypted, provider) { + Ok(record) => (record.with_ciphertext_hash(ciphertext_hash.clone()), true), + Err(OtpError::Crypto(_)) => { + unavailable_entries = unavailable_entries.saturating_add(1); + operation.update(|progress| { + progress.unavailable = progress.unavailable.saturating_add(1) + }); + operation.update(|progress| { + progress.inspected = progress.inspected.saturating_add(1) + }); + continue; + } + Err(OtpError::Repository(error)) => return Err(error.into()), + Err(error) => return Err(error.into()), + } + }; + if record.is_totp { + rows.push(cached_row(path, shared.contains(path))); + 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()); + } + } + records.insert(path_text, record); + operation.update(|progress| progress.inspected = progress.inspected.saturating_add(1)); + } + + operation.check_cancelled()?; + if initial_inventory != inventory(self.repository)? { + return Err(MobileTotpError::ConcurrentModification); + } + operation.update(|progress| progress.phase = MobileTotpDiscoveryPhase::Saving); + let catalog = CachedCatalog { + version: CACHE_VERSION, + store, + entries: records, + }; + if let Err(error) = save_cache(cache_path, &catalog) { + cache_notice = Some(error.to_string()); + } + + sort_rows(&mut rows); + let selected = rows.iter().filter(|row| row.shared_with_watch).count(); + operation.update(|progress| progress.phase = MobileTotpDiscoveryPhase::Complete); + Ok(MobileTotpPage { + rows, + unavailable_entries, + cache_notice, + watch: snapshot_status(selected), + }) + } + + pub fn cached_page( + &self, + shared: &BTreeSet, + cache_path: &Path, + ) -> Option { + let store = store_identity(self.repository.root_path()); + let (catalog, _) = load_cache(cache_path, &store); + let mut rows: Vec<_> = catalog? + .entries + .into_iter() + .filter(|(_, record)| record.is_totp) + .filter_map(|(path, _)| EntryPath::parse(&path).ok()) + .map(|path| cached_row(&path, shared.contains(&path))) + .collect(); + sort_rows(&mut rows); + let selected = rows.iter().filter(|row| row.shared_with_watch).count(); + Some(MobileTotpPage { + rows, + unavailable_entries: 0, + cache_notice: None, watch: snapshot_status(selected), }) } @@ -234,6 +460,150 @@ impl<'a> MobileTotpService<'a> { } } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +struct CachedRecord { + ciphertext_hash: String, + is_totp: bool, +} + +impl CachedRecord { + fn with_ciphertext_hash(mut self, ciphertext_hash: String) -> Self { + self.ciphertext_hash = ciphertext_hash; + self + } +} + +#[derive(Debug, Deserialize, Eq, PartialEq, Serialize)] +struct CachedCatalog { + version: u32, + store: String, + entries: BTreeMap, +} + +fn inventory( + repository: &Repository, +) -> Result, RepositoryError> { + repository + .snapshot()? + .entries() + .map(|entry| { + let encrypted = repository.read_entry(entry.path())?; + let ciphertext_hash = digest(encrypted.as_bytes()); + Ok((entry.path().clone(), encrypted, ciphertext_hash)) + }) + .collect() +} + +fn inspect_entry( + repository: &Repository, + keys: &KeyStore, + path: &EntryPath, + encrypted: &EncryptedEntry, + provider: &mut impl SecretProvider, +) -> Result { + let is_totp = match OtpService::new(repository, keys).uri_encrypted(path, encrypted, provider) { + Ok(uri) => uri.kind() == OtpKind::Totp, + Err(OtpError::MissingUri { .. } | OtpError::AmbiguousUri { .. }) => false, + Err(OtpError::Crypto(error)) => return Err(OtpError::Crypto(error)), + Err(OtpError::Repository(error)) => return Err(OtpError::Repository(error)), + Err(_) => false, + }; + Ok(CachedRecord { + ciphertext_hash: String::new(), + is_totp, + }) +} + +fn load_cache(path: &Path, store: &str) -> (Option, Option) { + if let Ok(metadata) = fs::symlink_metadata(path) + && (metadata.file_type().is_symlink() || !metadata.is_file()) + { + return ( + None, + Some("The TOTP cache was not a regular file and was rebuilt.".to_owned()), + ); + } + let contents = match fs::read_to_string(path) { + Ok(contents) => contents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return (None, None), + Err(_) => { + return ( + None, + Some("The TOTP cache could not be read and was rebuilt.".to_owned()), + ); + } + }; + let catalog = toml::from_str::(&contents) + .ok() + .filter(|catalog| catalog.version == CACHE_VERSION && catalog.store == store); + match catalog { + Some(catalog) => (Some(catalog), None), + None => ( + None, + Some("The TOTP cache was outdated or damaged and was rebuilt.".to_owned()), + ), + } +} + +fn save_cache(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)?; + let directory = Dir::open_ambient_dir(parent, ambient_authority()) + .map_err(|_| MobileTotpCacheError::Write)?; + if let Ok(metadata) = directory.symlink_metadata(name) + && (metadata.file_type().is_symlink() || !metadata.is_file()) + { + return Err(MobileTotpCacheError::Write); + } + let mut temporary = TempFile::new(&directory).map_err(|_| MobileTotpCacheError::Write)?; + set_private_permissions(&temporary)?; + temporary + .write_all(serialized.as_bytes()) + .and_then(|()| temporary.as_file().sync_all()) + .and_then(|()| temporary.replace(name)) + .and_then(|()| directory.open(".").and_then(|file| file.sync_all())) + .map_err(|_| MobileTotpCacheError::Write) +} + +#[cfg(unix)] +fn set_private_permissions(temporary: &TempFile<'_>) -> Result<(), MobileTotpCacheError> { + use cap_std::fs::{Permissions, PermissionsExt as _}; + + temporary + .as_file() + .set_permissions(Permissions::from_mode(0o600)) + .map_err(|_| MobileTotpCacheError::Write) +} + +#[cfg(not(unix))] +fn set_private_permissions(_temporary: &TempFile<'_>) -> Result<(), MobileTotpCacheError> { + Ok(()) +} + +fn store_identity(root: &Path) -> String { + digest(root.to_string_lossy().as_bytes()) +} + +fn digest(bytes: &[u8]) -> String { + HEXLOWER.encode(&Sha256::digest(bytes)) +} + +#[derive(Debug)] +enum MobileTotpCacheError { + Encode, + Write, +} + +impl fmt::Display for MobileTotpCacheError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Encode => formatter.write_str("The TOTP cache could not be encoded."), + Self::Write => formatter.write_str("The TOTP cache could not be saved."), + } + } +} + fn row( path: &EntryPath, issuer: Option<&str>, @@ -250,6 +620,33 @@ fn row( } } +fn cached_row(path: &EntryPath, shared_with_watch: bool) -> MobileTotpRow { + let path = path.to_string(); + let title = path.rsplit('/').next().unwrap_or(&path).to_owned(); + MobileTotpRow { + path: path.clone(), + issuer: None, + account: title.clone(), + title, + detail: path, + shared_with_watch, + } +} + +fn sort_rows(rows: &mut [MobileTotpRow]) { + rows.sort_by(|left, right| { + left.title + .to_lowercase() + .cmp(&right.title.to_lowercase()) + .then_with(|| { + left.account + .to_lowercase() + .cmp(&right.account.to_lowercase()) + }) + .then_with(|| left.path.cmp(&right.path)) + }); +} + fn snapshot_status(selected: usize) -> MobileWatchSnapshotStatus { if selected == 0 { MobileWatchSnapshotStatus { @@ -275,6 +672,7 @@ fn snapshot_status(selected: usize) -> MobileWatchSnapshotStatus { pub enum MobileTotpError { Repository(RepositoryError), Otp(OtpError), + ConcurrentModification, } impl fmt::Display for MobileTotpError { @@ -282,6 +680,8 @@ impl fmt::Display for MobileTotpError { match self { Self::Repository(error) => error.fmt(formatter), Self::Otp(error) => error.fmt(formatter), + Self::ConcurrentModification => formatter + .write_str("the password store changed while TOTP discovery was in progress"), } } } diff --git a/crates/storage/src/mutation.rs b/crates/storage/src/mutation.rs index 4869c9c..df8b388 100644 --- a/crates/storage/src/mutation.rs +++ b/crates/storage/src/mutation.rs @@ -612,14 +612,6 @@ impl<'a> TreeMutator<'a> { path: repository.git_directory().to_owned(), }); } - if let Some(path) = snapshot - .collisions() - .find(|path| path.starts_with(root.as_path())) - { - return Err(MutationError::SourceTypeCollision { - path: (*path).clone(), - }); - } Ok(()) } @@ -760,7 +752,6 @@ pub enum MutationError { SameObject, DestinationInsideSource, DestinationDirectoryMissing { directory: DirectoryPath }, - SourceTypeCollision { path: std::path::PathBuf }, DestinationTypeCollision { path: std::path::PathBuf }, UnsafePolicyOverwrite { directory: DirectoryPath }, UnsupportedAuxiliary { path: std::path::PathBuf }, @@ -788,11 +779,6 @@ impl fmt::Display for MutationError { formatter, "trailing-slash destination directory does not exist: {directory}" ), - Self::SourceTypeCollision { path } => write!( - formatter, - "source subtree contains an entry/directory collision: {}", - path.display() - ), Self::DestinationTypeCollision { path } => write!( formatter, "directory destination collides with password entry: {}", diff --git a/crates/storage/src/otp.rs b/crates/storage/src/otp.rs index f5bd390..2e234a1 100644 --- a/crates/storage/src/otp.rs +++ b/crates/storage/src/otp.rs @@ -656,10 +656,21 @@ impl<'a> OtpService<'a> { pub fn uri(&self, entry: &str, provider: &mut impl SecretProvider) -> Result { let path = parse_entry(entry)?; let ciphertext = self.repository.read_entry(&path)?; - let plaintext = self.keys.decrypt(&ciphertext, provider)?; - find_uri(&plaintext, &path)? + self.uri_encrypted(&path, &ciphertext, provider) + } + + pub(crate) fn uri_encrypted( + &self, + path: &EntryPath, + ciphertext: &EncryptedEntry, + provider: &mut impl SecretProvider, + ) -> Result { + let plaintext = self.keys.decrypt(ciphertext, provider)?; + find_uri(&plaintext, path)? .map(|(_, uri)| uri) - .ok_or(OtpError::MissingUri { entry: path }) + .ok_or_else(|| OtpError::MissingUri { + entry: path.clone(), + }) } pub fn code( diff --git a/crates/storage/src/repository.rs b/crates/storage/src/repository.rs index f344082..1dc5eec 100644 --- a/crates/storage/src/repository.rs +++ b/crates/storage/src/repository.rs @@ -1,7 +1,7 @@ //! Capability-scoped password-store repository discovery and atomic file access. use std::{ - collections::{BTreeMap, BTreeSet}, + collections::BTreeMap, error::Error, ffi::{OsStr, OsString}, fmt, fs, @@ -9,6 +9,9 @@ use std::{ path::{Component, Path, PathBuf}, }; +#[cfg(test)] +use std::collections::BTreeSet; + use cap_std::{ambient_authority, fs::Dir}; use cap_tempfile::TempFile; use zeroize::Zeroize; @@ -262,7 +265,6 @@ pub struct RepositorySnapshot { recipients: BTreeMap, git_repositories: BTreeMap, auxiliary_files: BTreeMap, - collisions: BTreeSet, } impl RepositorySnapshot { @@ -286,10 +288,6 @@ impl RepositorySnapshot { self.auxiliary_files.values() } - pub fn collisions(&self) -> impl ExactSizeIterator { - self.collisions.iter() - } - /// Resolve an upstream-style display path. A trailing slash explicitly selects a directory. pub fn resolve(&self, input: &str) -> Result, RepositoryError> { let directory_only = input.ends_with('/') || (cfg!(windows) && input.ends_with('\\')); @@ -310,10 +308,7 @@ impl RepositorySnapshot { let entry = EntryPath::parse(trimmed)?; let entry_record = self.entries.get(&entry); match (entry_record, directory_record) { - (Some(_), Some(_)) => Err(RepositoryError::AmbiguousPath { - path: entry.0.clone(), - }), - (Some(entry), None) => Ok(ResolvedObject::Entry(entry)), + (Some(entry), _) => Ok(ResolvedObject::Entry(entry)), (None, Some(directory)) => Ok(ResolvedObject::Directory(directory)), (None, None) => Err(RepositoryError::NotFound { path: entry.0.clone(), @@ -390,14 +385,6 @@ impl Repository { pub fn snapshot(&self) -> Result { let mut snapshot = RepositorySnapshot::default(); scan_directory(&self.root, &DirectoryPath::root(), &mut snapshot)?; - for entry in snapshot.entries.keys() { - if snapshot - .directories - .contains_key(&DirectoryPath(entry.0.clone())) - { - snapshot.collisions.insert(entry.0.clone()); - } - } Ok(snapshot) } @@ -540,7 +527,7 @@ impl Repository { { let (parent, file_name, created) = self.create_entry_parent(path)?; let encrypted_path = path.encrypted_relative_path(); - if let Err(error) = validate_write_target(&parent, &file_name, &path.0, &encrypted_path) { + if let Err(error) = validate_write_target(&parent, &file_name, &encrypted_path) { return self.rollback_created(created, error); } @@ -598,7 +585,6 @@ impl Repository { .0 .file_name() .expect("non-root directory has a file name"); - reject_entry_directory_collision(&parent_handle, name, &parent.0)?; let metadata = child_metadata(&parent_handle, name, &directory.0)?; let Some(metadata) = metadata else { current = directory.parent(); @@ -651,7 +637,6 @@ impl Repository { .as_path() .file_name() .expect("non-root directory has a file name"); - reject_entry_directory_collision(&parent_handle, name, parent.as_path())?; let Some(metadata) = child_metadata(&parent_handle, name, directory.as_path())? else { return Ok(false); }; @@ -691,7 +676,6 @@ impl Repository { file_name.push(".gpg"); return Ok((directory, file_name)); } - reject_entry_directory_collision(&directory, name, &relative)?; relative.push(name); let metadata = child_metadata(&directory, name, &relative)?; let Some(metadata) = metadata else { @@ -725,9 +709,6 @@ impl Repository { file_name.push(".gpg"); return Ok((directory, file_name, created)); } - if let Err(error) = reject_entry_directory_collision(&directory, name, &relative) { - return self.rollback_created(created, error); - } relative.push(name); match child_metadata(&directory, name, &relative) { Ok(Some(metadata)) => { @@ -773,9 +754,6 @@ impl Repository { let Component::Normal(name) = component else { unreachable!("DirectoryPath is validated") }; - if let Err(error) = reject_entry_directory_collision(&directory, name, &relative) { - return self.rollback_created(created, error); - } relative.push(name); match child_metadata(&directory, name, &relative) { Ok(Some(metadata)) => { @@ -845,7 +823,6 @@ impl Repository { let Component::Normal(name) = component else { unreachable!("normalized directory path has only normal components") }; - reject_entry_directory_collision(&directory, name, &relative)?; relative.push(name); let metadata = child_metadata(&directory, name, &relative)?.ok_or_else(|| { RepositoryError::NotFound { @@ -899,9 +876,6 @@ pub enum RepositoryError { Collision { path: PathBuf, }, - AmbiguousPath { - path: PathBuf, - }, NotFound { path: PathBuf, }, @@ -962,11 +936,6 @@ impl fmt::Display for RepositoryError { "password-store entry collides with a directory: {}", path.display() ), - Self::AmbiguousPath { path } => write!( - formatter, - "password-store path is both an entry and directory; add a trailing slash for the directory: {}", - path.display() - ), Self::NotFound { path } => { write!( formatter, @@ -1198,38 +1167,11 @@ fn require_regular_file( } } -fn reject_entry_directory_collision( - directory: &Dir, - name: &OsStr, - parent: &Path, -) -> Result<(), RepositoryError> { - let mut encrypted_name = name.to_os_string(); - encrypted_name.push(".gpg"); - let logical = parent.join(name); - if child_metadata(directory, &encrypted_name, &logical)?.is_some() { - return Err(RepositoryError::Collision { path: logical }); - } - Ok(()) -} - fn validate_write_target( parent: &Dir, file_name: &OsStr, - logical: &Path, encrypted: &Path, ) -> Result<(), RepositoryError> { - if let Some(metadata) = - child_metadata(parent, logical.file_name().unwrap_or_default(), logical)? - { - if metadata.is_dir() { - return Err(RepositoryError::Collision { - path: logical.to_owned(), - }); - } - return Err(RepositoryError::UnsupportedFileType { - path: logical.to_owned(), - }); - } if let Some(metadata) = child_metadata(parent, file_name, encrypted)? { require_regular_file(metadata, encrypted)?; } diff --git a/crates/storage/tests/mobile_totp.rs b/crates/storage/tests/mobile_totp.rs index f277957..c2807d0 100644 --- a/crates/storage/tests/mobile_totp.rs +++ b/crates/storage/tests/mobile_totp.rs @@ -2,13 +2,20 @@ mod support; -use std::collections::{BTreeMap, BTreeSet}; +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, +}; use ironstorage::{ crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError}, - mobile_totp::{MobileTotpService, MobileWatchSnapshotState}, + mobile_totp::{ + MobileTotpDiscoveryPhase, MobileTotpError, MobileTotpOperation, MobileTotpService, + MobileWatchSnapshotState, + }, + otp::OtpError, recipient::RecipientPolicyManager, - repository::{EntryPath, Repository, SecretBytes}, + repository::{EncryptedEntry, EntryPath, Repository, SecretBytes}, }; use support::compatibility::{FixtureSet, TestResult}; @@ -42,6 +49,63 @@ impl SecretProvider for FixtureSecrets { } } +struct CountingSecrets { + inner: FixtureSecrets, + requests: usize, +} + +impl CountingSecrets { + fn all(fixture: &FixtureSet) -> Self { + Self { + inner: FixtureSecrets::all(fixture), + requests: 0, + } + } +} + +impl SecretProvider for CountingSecrets { + fn secret_for(&mut self, key: &KeyInfo) -> Result { + self.requests += 1; + self.inner.secret_for(key) + } +} + +struct CancellingSecrets<'a> { + inner: FixtureSecrets, + operation: &'a MobileTotpOperation, + requests: usize, +} + +struct MutatingSecrets<'a> { + inner: FixtureSecrets, + repository: &'a Repository, + path: EntryPath, + replacement: Option, +} + +impl SecretProvider for MutatingSecrets<'_> { + fn secret_for(&mut self, key: &KeyInfo) -> Result { + let secret = self.inner.secret_for(key)?; + if let Some(replacement) = self.replacement.take() { + self.repository + .write_entry(&self.path, &replacement) + .expect("fixture mutation succeeds"); + } + Ok(secret) + } +} + +impl SecretProvider for CancellingSecrets<'_> { + fn secret_for(&mut self, key: &KeyInfo) -> Result { + self.requests += 1; + let secret = self.inner.secret_for(key)?; + if self.requests == 1 { + self.operation.cancel(); + } + Ok(secret) + } +} + #[test] fn mobile_totp_catalog_details_and_watch_selection_are_storage_owned() -> TestResult { let fixture = FixtureSet::load()?; @@ -99,6 +163,218 @@ fn mobile_totp_catalog_details_and_watch_selection_are_storage_owned() -> TestRe Ok(()) } +#[test] +fn totp_cache_reuses_ciphertext_hashes_and_removes_deleted_entries() -> TestResult { + let fixture = FixtureSet::load()?; + let store = fixture.materialize_store("basic")?; + let repository = Repository::open(store.path())?; + let keys = KeyStore::load(fixture.path("keys"))?; + write_plaintext( + &repository, + &keys, + "otp/alice", + b"password\notpauth://totp/Acme:alice@example.com?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Acme\n", + )?; + for path in ["team-a", "team-a/shared", "team-b/shared"] { + write_plaintext( + &repository, + &keys, + path, + b"otpauth://totp/Shared?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ\n", + )?; + } + write_plaintext(&repository, &keys, "ordinary", b"password\nlogin: alice\n")?; + let cache_directory = tempfile::tempdir()?; + let cache = cache_directory.path().join("totp-catalog.toml"); + let service = MobileTotpService::new(&repository, &keys); + + let cold = MobileTotpOperation::default(); + let mut cold_secrets = CountingSecrets::all(&fixture); + let page = service.discover(&BTreeSet::new(), &mut cold_secrets, &cache, &cold)?; + assert!(page.rows().iter().any(|row| row.path() == "otp/alice")); + assert!(page.rows().iter().any(|row| row.path() == "team-a")); + assert!(page.rows().iter().any(|row| row.path() == "team-a/shared")); + assert!(page.rows().iter().any(|row| row.path() == "team-b/shared")); + assert_eq!(cold.progress().phase(), MobileTotpDiscoveryPhase::Complete); + assert_eq!(cold.progress().inspected(), cold.progress().total()); + assert!(cold_secrets.requests > 0); + + let encoded = fs::read_to_string(&cache)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + + assert_eq!(fs::metadata(&cache)?.permissions().mode() & 0o777, 0o600); + } + assert!(encoded.contains("is_totp = true")); + assert!(encoded.contains("ciphertext_hash =")); + assert!(encoded.contains("otp/alice")); + for secret in [ + "otpauth://", + "secret=", + "alice@example.com", + "Acme", + "password", + ] { + assert!(!encoded.contains(secret), "cache leaked {secret}"); + } + + let warm = MobileTotpOperation::default(); + let mut warm_secrets = CountingSecrets::all(&fixture); + let warm_page = service.discover(&BTreeSet::new(), &mut warm_secrets, &cache, &warm)?; + assert_eq!(warm_secrets.requests, 0); + assert_eq!(warm.progress().cache_hits(), warm.progress().total()); + assert_eq!(warm_page.rows(), page.rows()); + assert_eq!( + service + .cached_page(&BTreeSet::new(), &cache) + .expect("created cache") + .rows(), + page.rows() + ); + + write_plaintext( + &repository, + &keys, + "ordinary", + b"otpauth://totp/New:new@example.com?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=New\n", + )?; + let changed = MobileTotpOperation::default(); + let mut changed_secrets = CountingSecrets::all(&fixture); + let changed_page = + service.discover(&BTreeSet::new(), &mut changed_secrets, &cache, &changed)?; + assert_eq!(changed_secrets.requests, 1); + assert_eq!( + changed.progress().cache_hits() + 1, + changed.progress().total() + ); + assert!( + changed_page + .rows() + .iter() + .any(|row| row.path() == "ordinary") + ); + + repository.remove_entry(&EntryPath::parse("otp/alice")?)?; + let deleted = MobileTotpOperation::default(); + let mut deleted_secrets = CountingSecrets::all(&fixture); + let deleted_page = + service.discover(&BTreeSet::new(), &mut deleted_secrets, &cache, &deleted)?; + assert_eq!(deleted_secrets.requests, 0); + assert!( + deleted_page + .rows() + .iter() + .all(|row| row.path() != "otp/alice") + ); + assert!( + service + .cached_page(&BTreeSet::new(), &cache) + .expect("updated cache") + .rows() + .iter() + .all(|row| row.path() != "otp/alice") + ); + + let cancelled = MobileTotpOperation::default(); + cancelled.cancel(); + let mut cancelled_secrets = CountingSecrets::all(&fixture); + assert!(matches!( + service.discover(&BTreeSet::new(), &mut cancelled_secrets, &cache, &cancelled,), + Err(MobileTotpError::Otp(OtpError::Cancelled)) + )); + assert_eq!( + cancelled.progress().phase(), + MobileTotpDiscoveryPhase::Cancelled + ); + + fs::write(&cache, "not a TOTP catalog")?; + let recovery = MobileTotpOperation::default(); + let mut recovery_secrets = CountingSecrets::all(&fixture); + let recovered = service.discover(&BTreeSet::new(), &mut recovery_secrets, &cache, &recovery)?; + assert!(recovered.cache_notice().is_some()); + assert!(service.cached_page(&BTreeSet::new(), &cache).is_some()); + + let other_store = fixture.materialize_store("nested")?; + let other_repository = Repository::open(other_store.path())?; + assert!( + MobileTotpService::new(&other_repository, &keys) + .cached_page(&BTreeSet::new(), &cache) + .is_none() + ); + Ok(()) +} + +#[test] +fn cancelled_discovery_checkpoints_completed_entries() -> 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 cache_directory = tempfile::tempdir()?; + let cache = cache_directory.path().join("totp-catalog.toml"); + let service = MobileTotpService::new(&repository, &keys); + let interrupted = MobileTotpOperation::default(); + let mut secrets = CancellingSecrets { + inner: FixtureSecrets::all(&fixture), + operation: &interrupted, + requests: 0, + }; + + assert!(matches!( + service.discover(&BTreeSet::new(), &mut secrets, &cache, &interrupted,), + Err(MobileTotpError::Otp(OtpError::Cancelled)) + )); + assert!(cache.is_file()); + + let resumed = MobileTotpOperation::default(); + let mut resumed_secrets = CountingSecrets::all(&fixture); + service.discover(&BTreeSet::new(), &mut resumed_secrets, &cache, &resumed)?; + assert!(resumed.progress().cache_hits() >= 1); + assert!(resumed_secrets.requests < resumed.progress().total() as usize); + Ok(()) +} + +#[test] +fn discovery_rejects_a_result_when_ciphertext_changes_mid_scan() -> 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("ordinary")?; + write_plaintext(&repository, &keys, "ordinary", b"password\n")?; + let recipients = + RecipientPolicyManager::new(&repository, &keys).resolve_for_entry(&path, None)?; + let replacement = keys.encrypt( + SecretBytes::new(b"changed password\n".to_vec()), + recipients.recipients(), + )?; + let cache_directory = tempfile::tempdir()?; + let cache = cache_directory.path().join("totp-catalog.toml"); + let operation = MobileTotpOperation::default(); + let mut secrets = MutatingSecrets { + inner: FixtureSecrets::all(&fixture), + repository: &repository, + path, + replacement: Some(replacement), + }; + + assert!(matches!( + MobileTotpService::new(&repository, &keys).discover( + &BTreeSet::new(), + &mut secrets, + &cache, + &operation, + ), + Err(MobileTotpError::ConcurrentModification) + )); + assert_ne!( + operation.progress().phase(), + MobileTotpDiscoveryPhase::Complete + ); + Ok(()) +} + fn write_plaintext( repository: &Repository, keys: &KeyStore, diff --git a/crates/storage/tests/repository_core.rs b/crates/storage/tests/repository_core.rs index 432371d..67e6937 100644 --- a/crates/storage/tests/repository_core.rs +++ b/crates/storage/tests/repository_core.rs @@ -24,7 +24,6 @@ fn ordinary_nested_and_unicode_pass_trees_are_discovered() -> TestResult { assert_eq!(paths.len(), 6); assert!(paths.contains(&Path::new("email/personal").to_owned())); assert!(paths.contains(&Path::new("unicode/咖啡").to_owned())); - assert_eq!(snapshot.collisions().len(), 0); assert_eq!(snapshot.auxiliary_files().len(), 0); assert_eq!(snapshot.recipient_policies().len(), 3); @@ -86,38 +85,54 @@ fn innermost_git_repository_is_selected_without_scanning_git_objects() -> TestRe } #[test] -fn entry_and_directory_ambiguity_requires_explicit_directory_syntax() -> TestResult { +fn entry_and_directory_with_the_same_logical_path_remain_independent() -> TestResult { let temporary = tempfile::tempdir()?; fs::create_dir(temporary.path().join("ambiguous"))?; fs::write(temporary.path().join("ambiguous.gpg"), b"ciphertext")?; + fs::write( + temporary.path().join("ambiguous/child.gpg"), + b"child ciphertext", + )?; let repository = Repository::open(temporary.path())?; let snapshot = repository.snapshot()?; - assert_eq!( - snapshot.resolve("ambiguous").expect_err("ambiguous path"), - RepositoryError::AmbiguousPath { - path: Path::new("ambiguous").to_owned() - } - ); + assert!(matches!( + snapshot.resolve("ambiguous")?, + ResolvedObject::Entry(entry) if entry.path().as_path() == Path::new("ambiguous") + )); assert!(matches!( snapshot.resolve("ambiguous/")?, ResolvedObject::Directory(directory) if directory.path().as_path() == Path::new("ambiguous") )); assert_eq!( - snapshot.collisions().collect::>(), - [&Path::new("ambiguous").to_owned()] + snapshot + .entries() + .map(|entry| entry.path().as_path()) + .collect::>(), + [Path::new("ambiguous"), Path::new("ambiguous/child")] ); - let original = fs::read(temporary.path().join("ambiguous.gpg"))?; - assert!(matches!( - repository.write_entry( - &EntryPath::parse("ambiguous")?, - &EncryptedEntry::new(b"replacement".to_vec()) - ), - Err(RepositoryError::Collision { .. }) - )); - assert_eq!(fs::read(temporary.path().join("ambiguous.gpg"))?, original); + repository.write_entry( + &EntryPath::parse("ambiguous")?, + &EncryptedEntry::new(b"replacement".to_vec()), + )?; + repository.write_entry( + &EntryPath::parse("ambiguous/new-child")?, + &EncryptedEntry::new(b"new child".to_vec()), + )?; + assert_eq!( + repository + .read_entry(&EntryPath::parse("ambiguous")?)? + .as_bytes(), + b"replacement" + ); + assert_eq!( + repository + .read_entry(&EntryPath::parse("ambiguous/new-child")?)? + .as_bytes(), + b"new child" + ); Ok(()) } diff --git a/crates/storage/tests/tree_mutations.rs b/crates/storage/tests/tree_mutations.rs index 0b4f727..6a79700 100644 --- a/crates/storage/tests/tree_mutations.rs +++ b/crates/storage/tests/tree_mutations.rs @@ -320,7 +320,7 @@ fn entry_collisions_require_confirmation_unless_forced() -> TestResult { } #[test] -fn mutation_rejects_ambiguous_sources_and_same_object_targets() -> TestResult { +fn mutation_prefers_an_entry_over_its_same_named_directory() -> TestResult { let fixture = FixtureSet::load()?; let store = fixture.materialize_store("basic")?; fs::create_dir(store.path().join("email/personal"))?; @@ -329,22 +329,26 @@ fn mutation_rejects_ambiguous_sources_and_same_object_targets() -> TestResult { let mut provider = Secrets::all(&fixture); let mutator = TreeMutator::new(&repository, &keys); - assert!(matches!( - mutator.copy( - &CopyRequest { - source: "email/personal".into(), - destination: "elsewhere".into(), - force: false, - }, - OverwriteDecision::Allow, - None, + mutator.copy( + &CopyRequest { + source: "email/personal".into(), + destination: "elsewhere".into(), + force: false, + }, + OverwriteDecision::Allow, + None, + &mut provider, + &mut Committer::default(), + )?; + assert!(store.path().join("email/personal").is_dir()); + assert_eq!( + keys.decrypt( + &repository.read_entry(&EntryPath::parse("elsewhere")?)?, &mut provider, - &mut Committer::default() - ), - Err(MutationError::Repository( - ironstorage::repository::RepositoryError::AmbiguousPath { .. } - )) - )); + )? + .expose(), + fixture.read("expected/basic/email/personal.txt")? + ); assert!(matches!( mutator.copy( &CopyRequest {