diff --git a/apple/Generated/ironstorage_apple.swift b/apple/Generated/ironstorage_apple.swift index ba9031f..f3c5447 100644 --- a/apple/Generated/ironstorage_apple.swift +++ b/apple/Generated/ironstorage_apple.swift @@ -595,6 +595,8 @@ public protocol MobileAuthenticationProtocol: AnyObject, Sendable { func copyEntryField(path: String, field: UInt64) throws -> MobileEntryCopy + func copyTotpCode(path: String, unixSeconds: UInt64) throws -> MobileEntryCopy + func discardEntryEditor(editor: UInt64) throws func entryEditor(editor: UInt64) throws -> MobileEntryEditorPage @@ -617,12 +619,20 @@ public protocol MobileAuthenticationProtocol: AnyObject, Sendable { func setBiometricUnlock(enabled: Bool) throws -> MobileAuthenticationState + func setTotpWatchShared(path: String, shared: Bool, unixSeconds: UInt64) throws -> MobileTotpDetail + func state() throws -> MobileAuthenticationState + func totpDetail(path: String, unixSeconds: UInt64) throws -> MobileTotpDetail + + func totpPage() throws -> MobileTotpPage + func touchUserActivity() throws func unlockEntry(path: String, passphrase: String?) throws -> MobileAuthenticationState + func unlockTotp(passphrase: String?) throws -> MobileAuthenticationState + func updateEntryEditor(editor: UInt64, fields: [MobileEntryEditorInput]) throws -> MobileEntryEditorPage } @@ -733,6 +743,17 @@ open func copyEntryField(path: String, field: UInt64)throws -> MobileEntryCopy }) } +open func copyTotpCode(path: String, unixSeconds: UInt64)throws -> MobileEntryCopy { + return try FfiConverterTypeMobileEntryCopy_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobileauthentication_copy_totp_code( + self.uniffiCloneHandle(), + FfiConverterString.lower(path), + FfiConverterUInt64.lower(unixSeconds),uniffiCallStatus + ) +}) +} + open func discardEntryEditor(editor: UInt64)throws {try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { uniffiCallStatus in uniffi_ironstorage_apple_fn_method_mobileauthentication_discard_entry_editor( @@ -849,6 +870,18 @@ open func setBiometricUnlock(enabled: Bool)throws -> MobileAuthenticationState }) } +open func setTotpWatchShared(path: String, shared: Bool, unixSeconds: UInt64)throws -> MobileTotpDetail { + return try FfiConverterTypeMobileTotpDetail_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobileauthentication_set_totp_watch_shared( + self.uniffiCloneHandle(), + FfiConverterString.lower(path), + FfiConverterBool.lower(shared), + FfiConverterUInt64.lower(unixSeconds),uniffiCallStatus + ) +}) +} + open func state()throws -> MobileAuthenticationState { return try FfiConverterTypeMobileAuthenticationState_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { uniffiCallStatus in @@ -858,6 +891,26 @@ open func state()throws -> MobileAuthenticationState { }) } +open func totpDetail(path: String, unixSeconds: UInt64)throws -> MobileTotpDetail { + return try FfiConverterTypeMobileTotpDetail_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobileauthentication_totp_detail( + self.uniffiCloneHandle(), + FfiConverterString.lower(path), + FfiConverterUInt64.lower(unixSeconds),uniffiCallStatus + ) +}) +} + +open func totpPage()throws -> MobileTotpPage { + return try FfiConverterTypeMobileTotpPage_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobileauthentication_totp_page( + self.uniffiCloneHandle(),uniffiCallStatus + ) +}) +} + open func touchUserActivity()throws {try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { uniffiCallStatus in uniffi_ironstorage_apple_fn_method_mobileauthentication_touch_user_activity( @@ -877,6 +930,16 @@ open func unlockEntry(path: String, passphrase: String?)throws -> MobileAuthent }) } +open func unlockTotp(passphrase: String?)throws -> MobileAuthenticationState { + return try FfiConverterTypeMobileAuthenticationState_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobileauthentication_unlock_totp( + self.uniffiCloneHandle(), + FfiConverterOptionString.lower(passphrase),uniffiCallStatus + ) +}) +} + open func updateEntryEditor(editor: UInt64, fields: [MobileEntryEditorInput])throws -> MobileEntryEditorPage { return try FfiConverterTypeMobileEntryEditorPage_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { uniffiCallStatus in @@ -2682,6 +2745,266 @@ public func FfiConverterTypeMobileShell_lower(_ value: MobileShell) -> RustBuffe } +public struct MobileTotpDetail: Equatable, Hashable { + public var path: String + public var issuer: String? + public var account: String + public var code: String + public var validUntil: UInt64 + public var period: UInt64 + public var sharedWithWatch: Bool + public var watch: MobileWatchSnapshotStatus + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(path: String, issuer: String?, account: String, code: String, validUntil: UInt64, period: UInt64, sharedWithWatch: Bool, watch: MobileWatchSnapshotStatus) { + self.path = path + self.issuer = issuer + self.account = account + self.code = code + self.validUntil = validUntil + self.period = period + self.sharedWithWatch = sharedWithWatch + self.watch = watch + } + + + + +} + +#if compiler(>=6) +extension MobileTotpDetail: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileTotpDetail: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileTotpDetail { + return + try MobileTotpDetail( + path: FfiConverterString.read(from: &buf), + issuer: FfiConverterOptionString.read(from: &buf), + account: FfiConverterString.read(from: &buf), + code: FfiConverterString.read(from: &buf), + validUntil: FfiConverterUInt64.read(from: &buf), + period: FfiConverterUInt64.read(from: &buf), + sharedWithWatch: FfiConverterBool.read(from: &buf), + watch: FfiConverterTypeMobileWatchSnapshotStatus.read(from: &buf) + ) + } + + public static func write(_ value: MobileTotpDetail, into buf: inout [UInt8]) { + FfiConverterString.write(value.path, into: &buf) + FfiConverterOptionString.write(value.issuer, into: &buf) + FfiConverterString.write(value.account, into: &buf) + FfiConverterString.write(value.code, into: &buf) + FfiConverterUInt64.write(value.validUntil, into: &buf) + FfiConverterUInt64.write(value.period, into: &buf) + FfiConverterBool.write(value.sharedWithWatch, into: &buf) + FfiConverterTypeMobileWatchSnapshotStatus.write(value.watch, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileTotpDetail_lift(_ buf: RustBuffer) throws -> MobileTotpDetail { + return try FfiConverterTypeMobileTotpDetail.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileTotpDetail_lower(_ value: MobileTotpDetail) -> RustBuffer { + return FfiConverterTypeMobileTotpDetail.lower(value) +} + + +public struct MobileTotpPage: Equatable, Hashable { + public var rows: [MobileTotpRow] + public var unavailableEntries: UInt32 + 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) { + self.rows = rows + self.unavailableEntries = unavailableEntries + self.watch = watch + } + + + + +} + +#if compiler(>=6) +extension MobileTotpPage: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileTotpPage: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileTotpPage { + return + try MobileTotpPage( + rows: FfiConverterSequenceTypeMobileTotpRow.read(from: &buf), + unavailableEntries: FfiConverterUInt32.read(from: &buf), + watch: FfiConverterTypeMobileWatchSnapshotStatus.read(from: &buf) + ) + } + + public static func write(_ value: MobileTotpPage, into buf: inout [UInt8]) { + FfiConverterSequenceTypeMobileTotpRow.write(value.rows, into: &buf) + FfiConverterUInt32.write(value.unavailableEntries, into: &buf) + FfiConverterTypeMobileWatchSnapshotStatus.write(value.watch, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileTotpPage_lift(_ buf: RustBuffer) throws -> MobileTotpPage { + return try FfiConverterTypeMobileTotpPage.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileTotpPage_lower(_ value: MobileTotpPage) -> RustBuffer { + return FfiConverterTypeMobileTotpPage.lower(value) +} + + +public struct MobileTotpRow: Equatable, Hashable { + public var path: String + public var issuer: String? + public var account: String + public var title: String + public var detail: String + public var sharedWithWatch: Bool + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(path: String, issuer: String?, account: String, title: String, detail: String, sharedWithWatch: Bool) { + self.path = path + self.issuer = issuer + self.account = account + self.title = title + self.detail = detail + self.sharedWithWatch = sharedWithWatch + } + + + + +} + +#if compiler(>=6) +extension MobileTotpRow: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileTotpRow: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileTotpRow { + return + try MobileTotpRow( + path: FfiConverterString.read(from: &buf), + issuer: FfiConverterOptionString.read(from: &buf), + account: FfiConverterString.read(from: &buf), + title: FfiConverterString.read(from: &buf), + detail: FfiConverterString.read(from: &buf), + sharedWithWatch: FfiConverterBool.read(from: &buf) + ) + } + + public static func write(_ value: MobileTotpRow, into buf: inout [UInt8]) { + FfiConverterString.write(value.path, into: &buf) + FfiConverterOptionString.write(value.issuer, into: &buf) + FfiConverterString.write(value.account, into: &buf) + FfiConverterString.write(value.title, into: &buf) + FfiConverterString.write(value.detail, into: &buf) + FfiConverterBool.write(value.sharedWithWatch, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileTotpRow_lift(_ buf: RustBuffer) throws -> MobileTotpRow { + return try FfiConverterTypeMobileTotpRow.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileTotpRow_lower(_ value: MobileTotpRow) -> RustBuffer { + return FfiConverterTypeMobileTotpRow.lower(value) +} + + +public struct MobileWatchSnapshotStatus: Equatable, Hashable { + public var state: MobileWatchSnapshotState + public var detail: String + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(state: MobileWatchSnapshotState, detail: String) { + self.state = state + self.detail = detail + } + + + + +} + +#if compiler(>=6) +extension MobileWatchSnapshotStatus: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileWatchSnapshotStatus: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileWatchSnapshotStatus { + return + try MobileWatchSnapshotStatus( + state: FfiConverterTypeMobileWatchSnapshotState.read(from: &buf), + detail: FfiConverterString.read(from: &buf) + ) + } + + public static func write(_ value: MobileWatchSnapshotStatus, into buf: inout [UInt8]) { + FfiConverterTypeMobileWatchSnapshotState.write(value.state, into: &buf) + FfiConverterString.write(value.detail, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileWatchSnapshotStatus_lift(_ buf: RustBuffer) throws -> MobileWatchSnapshotStatus { + return try FfiConverterTypeMobileWatchSnapshotStatus.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileWatchSnapshotStatus_lower(_ value: MobileWatchSnapshotStatus) -> RustBuffer { + return FfiConverterTypeMobileWatchSnapshotStatus.lower(value) +} + + public enum MobileAuthenticationErrorKind: Equatable, Hashable { @@ -4311,6 +4634,72 @@ public func FfiConverterTypeMobileTab_lower(_ value: MobileTab) -> RustBuffer { } + + +public enum MobileWatchSnapshotState: Equatable, Hashable { + + case unavailable + case pending + + + + + +} + +#if compiler(>=6) +extension MobileWatchSnapshotState: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileWatchSnapshotState: FfiConverterRustBuffer { + typealias SwiftType = MobileWatchSnapshotState + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileWatchSnapshotState { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .unavailable + + case 2: return .pending + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: MobileWatchSnapshotState, into buf: inout [UInt8]) { + switch value { + + + case .unavailable: + writeInt(&buf, Int32(1)) + + + case .pending: + writeInt(&buf, Int32(2)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileWatchSnapshotState_lift(_ buf: RustBuffer) throws -> MobileWatchSnapshotState { + return try FfiConverterTypeMobileWatchSnapshotState.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileWatchSnapshotState_lower(_ value: MobileWatchSnapshotState) -> RustBuffer { + return FfiConverterTypeMobileWatchSnapshotState.lower(value) +} + + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -4656,6 +5045,31 @@ fileprivate struct FfiConverterSequenceTypeMobilePasswordRow: FfiConverterRustBu return seq } } + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeMobileTotpRow: FfiConverterRustBuffer { + typealias SwiftType = [MobileTotpRow] + + public static func write(_ value: [MobileTotpRow], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeMobileTotpRow.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [MobileTotpRow] { + let len: Int32 = try readInt(&buf) + var seq = [MobileTotpRow]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeMobileTotpRow.read(from: &buf)) + } + return seq + } +} public func mobileAuthentication()throws -> MobileAuthentication { return try FfiConverterTypeMobileAuthentication_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { uniffiCallStatus in @@ -4784,6 +5198,9 @@ private let initializationResult: InitializationResult = { if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_copy_entry_field() != 17773) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_copy_totp_code() != 8344) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_discard_entry_editor() != 28195) { return InitializationResult.apiChecksumMismatch } @@ -4817,15 +5234,27 @@ private let initializationResult: InitializationResult = { if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_biometric_unlock() != 9486) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_totp_watch_shared() != 57472) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_state() != 60826) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_totp_detail() != 42762) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_totp_page() != 18529) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_touch_user_activity() != 18402) { return InitializationResult.apiChecksumMismatch } if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_unlock_entry() != 28139) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_unlock_totp() != 816) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_update_entry_editor() != 30139) { return InitializationResult.apiChecksumMismatch } diff --git a/apple/Generated/ironstorage_appleFFI.h b/apple/Generated/ironstorage_appleFFI.h index 40097e7..d126f9d 100644 --- a/apple/Generated/ironstorage_appleFFI.h +++ b/apple/Generated/ironstorage_appleFFI.h @@ -278,6 +278,11 @@ void uniffi_ironstorage_apple_fn_method_mobileauthentication_cancel(uint64_t ptr RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_copy_entry_field(uint64_t ptr, RustBuffer path, uint64_t field, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_COPY_TOTP_CODE +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_COPY_TOTP_CODE +RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_copy_totp_code(uint64_t ptr, RustBuffer path, uint64_t unix_seconds, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_DISCARD_ENTRY_EDITOR #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_DISCARD_ENTRY_EDITOR void uniffi_ironstorage_apple_fn_method_mobileauthentication_discard_entry_editor(uint64_t ptr, uint64_t editor, RustCallStatus *_Nonnull out_status @@ -333,11 +338,26 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_save_entry_ed RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_set_biometric_unlock(uint64_t ptr, int8_t enabled, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_TOTP_WATCH_SHARED +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_TOTP_WATCH_SHARED +RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_set_totp_watch_shared(uint64_t ptr, RustBuffer path, int8_t shared, uint64_t unix_seconds, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_STATE #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_STATE RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_state(uint64_t ptr, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_TOTP_DETAIL +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_TOTP_DETAIL +RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_totp_detail(uint64_t ptr, RustBuffer path, uint64_t unix_seconds, RustCallStatus *_Nonnull out_status +); +#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 +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_TOUCH_USER_ACTIVITY #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_TOUCH_USER_ACTIVITY void uniffi_ironstorage_apple_fn_method_mobileauthentication_touch_user_activity(uint64_t ptr, RustCallStatus *_Nonnull out_status @@ -348,6 +368,11 @@ void uniffi_ironstorage_apple_fn_method_mobileauthentication_touch_user_activity RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_unlock_entry(uint64_t ptr, RustBuffer path, RustBuffer passphrase, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_UNLOCK_TOTP +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_UNLOCK_TOTP +RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_unlock_totp(uint64_t ptr, RustBuffer passphrase, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_UPDATE_ENTRY_EDITOR #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_UPDATE_ENTRY_EDITOR RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_update_entry_editor(uint64_t ptr, uint64_t editor, RustBuffer fields, RustCallStatus *_Nonnull out_status @@ -814,6 +839,12 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_cancel(vo #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_COPY_ENTRY_FIELD uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_copy_entry_field(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_COPY_TOTP_CODE +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_COPY_TOTP_CODE +uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_copy_totp_code(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_DISCARD_ENTRY_EDITOR @@ -880,12 +911,30 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_save_entr #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_biometric_unlock(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_TOTP_WATCH_SHARED +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_TOTP_WATCH_SHARED +uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_totp_watch_shared(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_STATE #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_STATE uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_state(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_TOTP_DETAIL +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_TOTP_DETAIL +uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_totp_detail(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_TOTP_PAGE +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_TOTP_PAGE +uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_totp_page(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_TOUCH_USER_ACTIVITY @@ -898,6 +947,12 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_touch_use #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_UNLOCK_ENTRY uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_unlock_entry(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_UNLOCK_TOTP +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_UNLOCK_TOTP +uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_unlock_totp(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_UPDATE_ENTRY_EDITOR diff --git a/apple/Sources/App/IronStorageApp.swift b/apple/Sources/App/IronStorageApp.swift index aa36dd5..d0ac5d5 100644 --- a/apple/Sources/App/IronStorageApp.swift +++ b/apple/Sources/App/IronStorageApp.swift @@ -7,6 +7,9 @@ extension Notification.Name { static let ironStorageAuthenticationDidChange = Notification.Name( "de.rfc1437.ironstorage.authentication-did-change" ) + static let ironStorageWatchSnapshotDidChange = Notification.Name( + "de.rfc1437.ironstorage.watch-snapshot-did-change" + ) } @main @@ -56,6 +59,8 @@ private final class AppContext: NSObject, UITabBarControllerDelegate { let root: UIViewController = switch page.tab { case .passwords: PasswordDirectoryViewController(shellPage: page, authentication: authentication) + case .totp: + TotpListViewController(shellPage: page, authentication: authentication) case .preferences: PreferencesViewController(page: page, authentication: authentication) default: @@ -888,6 +893,738 @@ private final class PreferencesViewController: UITableViewController, MobileTabR } } +@MainActor +private final class TotpListViewController: UITableViewController, MobileTabRoot { + fileprivate let shellTab = MobileTab.totp + private let authentication: MobileAuthentication? + private var shellPage: MobilePage + private var page: MobileTotpPage? + private var loadTask: Task? + private var unlockTask: Task? + + init(shellPage: MobilePage, authentication: MobileAuthentication?) { + self.shellPage = shellPage + self.authentication = authentication + super.init(style: .insetGrouped) + title = shellPage.title + navigationItem.largeTitleDisplayMode = .always + refreshControl = UIRefreshControl() + refreshControl?.addTarget(self, action: #selector(refreshRequested), for: .valueChanged) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } + + deinit { + loadTask?.cancel() + unlockTask?.cancel() + NotificationCenter.default.removeObserver(self) + } + + override func viewDidLoad() { + super.viewDidLoad() + NotificationCenter.default.addObserver( + self, + selector: #selector(authenticationDidChange), + name: .ironStorageAuthenticationDidChange, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(localStoreDidChange), + name: .ironStorageLocalStoreDidChange, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(localStoreDidChange), + name: .ironStorageWatchSnapshotDidChange, + object: nil + ) + refreshState() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + refreshState() + } + + override func numberOfSections(in tableView: UITableView) -> Int { 1 } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + page?.rows.count ?? 0 + } + + override func tableView( + _ tableView: UITableView, + titleForFooterInSection section: Int + ) -> String? { + guard let page else { return nil } + let unavailable = page.unavailableEntries == 0 + ? "" + : " \(page.unavailableEntries) entries could not be inspected with the active key." + return page.watch.detail + unavailable + } + + override func tableView( + _ tableView: UITableView, + cellForRowAt indexPath: IndexPath + ) -> UITableViewCell { + let row = page?.rows[indexPath.row] + let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil) + var content = cell.defaultContentConfiguration() + content.image = UIImage(systemName: "timer") + content.text = row?.title + content.secondaryText = row?.detail + content.secondaryTextProperties.numberOfLines = 2 + if row?.sharedWithWatch == true { + content.secondaryText = [row?.detail, "Apple Watch selected"] + .compactMap { $0 } + .joined(separator: " • ") + } + cell.contentConfiguration = content + cell.accessoryType = .disclosureIndicator + cell.accessibilityLabel = [row?.title, row?.detail].compactMap { $0 }.joined(separator: ", ") + cell.accessibilityValue = row?.sharedWithWatch == true ? "Selected for Apple Watch" : nil + return cell + } + + 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 } + loadTask?.cancel() + loadTask = Task { [weak self] in + let result = await Task.detached(priority: .userInitiated) { + do { + try authentication.touchUserActivity() + return Result.success( + try authentication.totpDetail( + path: row.path, + unixSeconds: currentUnixSeconds() + ) + ) + } catch let error as MobileAuthenticationFfiError { + return .failure(AuthenticationFailure(error)) + } catch { + return .failure(.unexpected) + } + }.value + guard !Task.isCancelled, let self else { return } + switch result { + case let .success(detail): + navigationController?.pushViewController( + TotpDetailViewController(authentication: authentication, detail: detail), + animated: true + ) + case let .failure(failure): + handle(failure) + } + } + } + + @objc private func refreshRequested() { + refreshState(force: true) + } + + @objc private func authenticationDidChange() { + refreshState() + } + + @objc private func localStoreDidChange() { + guard (try? authentication?.state().unlocked) == true else { return } + loadPage() + } + + @objc private func unlockRequested() { + unlock(passphrase: nil) + } + + @objc private func lockRequested() { + guard let authentication else { return } + do { + try authentication.manualLock() + NotificationCenter.default.post( + name: .ironStorageAuthenticationDidChange, + object: authentication + ) + } catch let error as MobileAuthenticationFfiError { + presentAuthenticationFailure(AuthenticationFailure(error)) + } catch { + presentAuthenticationFailure(.unexpected) + } + } + + private func refreshState(force: Bool = false) { + guard shellPage.state == .ready else { + page = nil + showUnavailable( + title: shellPage.stateTitle, + detail: shellPage.stateDetail, + image: shellPage.systemImage + ) + 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() + } + } + + private func unlock(passphrase: String?) { + guard let authentication else { + presentAuthenticationFailure(.unavailable) + return + } + unlockTask?.cancel() + showLoading("Unlocking TOTP") + unlockTask = Task { [weak self] in + let result = await Task.detached(priority: .userInitiated) { + do { + return Result.success( + try authentication.unlockTotp(passphrase: passphrase) + ) + } catch let error as MobileAuthenticationFfiError { + return .failure(AuthenticationFailure(error)) + } catch { + return .failure(.unexpected) + } + }.value + guard !Task.isCancelled, let self else { return } + switch result { + case .success: + NotificationCenter.default.post( + name: .ironStorageAuthenticationDidChange, + object: authentication + ) + loadPage() + UIAccessibility.post(notification: .announcement, argument: "TOTP unlocked") + case let .failure(failure) + where passphrase == nil + && (failure.kind == .passphraseRequired + || failure.kind == .biometryUnavailable): + promptForPassphrase(message: failure.detail) + case let .failure(failure): + if failure.kind != .cancelled { handle(failure) } + } + } + } + + private func promptForPassphrase(message: String) { + let alert = UIAlertController( + title: "GPG Key Passphrase", + message: message, + preferredStyle: .alert + ) + alert.addTextField { field in + field.isSecureTextEntry = true + field.textContentType = .password + field.placeholder = "Passphrase" + field.returnKeyType = .go + } + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { [weak self] _ in + self?.refreshState() + }) + 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) + }) + present(alert, animated: true) + } + + private func loadPage() { + guard let authentication else { + presentAuthenticationFailure(.unavailable) + return + } + loadTask?.cancel() + showLoading("Loading TOTP") + loadTask = Task { [weak self] in + let result = await Task.detached(priority: .userInitiated) { + do { + return Result.success( + try authentication.totpPage() + ) + } catch let error as MobileAuthenticationFfiError { + return .failure(AuthenticationFailure(error)) + } catch { + return .failure(.unexpected) + } + }.value + guard !Task.isCancelled, let self else { return } + refreshControl?.endRefreshing() + 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" + ) + } else { + contentUnavailableConfiguration = nil + } + case let .failure(failure): + handle(failure) + } + } + } + + private func handle(_ failure: AuthenticationFailure) { + if failure.kind == .expired { + NotificationCenter.default.post( + name: .ironStorageAuthenticationDidChange, + object: authentication + ) + refreshState() + } else { + showUnavailable(title: failure.title, detail: failure.detail, image: "exclamationmark.triangle") + } + presentAuthenticationFailure(failure) + } + + private func showLoading(_ title: String) { + var configuration = UIContentUnavailableConfiguration.loading() + configuration.text = title + configuration.secondaryText = "Reading OTP metadata in secure storage." + contentUnavailableConfiguration = configuration + } + + private func showUnavailable(title: String, detail: String, image: String) { + var configuration = UIContentUnavailableConfiguration.empty() + configuration.image = UIImage(systemName: image) + configuration.text = title + configuration.secondaryText = detail + contentUnavailableConfiguration = configuration + refreshControl?.endRefreshing() + } +} + +@MainActor +private final class TotpDetailViewController: UITableViewController { + private let authentication: MobileAuthentication + private var detail: MobileTotpDetail + private let codeContainer = UIView() + private let issuerLabel = UILabel() + private let accountLabel = UILabel() + private let codeLabel = UILabel() + private let countdownLabel = UILabel() + private let progress = UIProgressView(progressViewStyle: .default) + private let watchSwitch = UISwitch() + private var timerTask: Task? + private var loadTask: Task? + private var clipboardTask: Task? + private var copiedValue: String? + + init(authentication: MobileAuthentication, detail: MobileTotpDetail) { + self.authentication = authentication + self.detail = detail + super.init(style: .insetGrouped) + navigationItem.largeTitleDisplayMode = .never + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } + + deinit { + timerTask?.cancel() + loadTask?.cancel() + clipboardTask?.cancel() + NotificationCenter.default.removeObserver(self) + } + + override func viewDidLoad() { + super.viewDidLoad() + configureHeader() + navigationItem.rightBarButtonItem = UIBarButtonItem( + image: UIImage(systemName: "doc.on.doc"), + style: .plain, + target: self, + action: #selector(copyRequested) + ) + navigationItem.rightBarButtonItem?.accessibilityLabel = "Copy current TOTP code" + watchSwitch.addTarget(self, action: #selector(watchSwitchChanged(_:)), for: .valueChanged) + NotificationCenter.default.addObserver( + self, + selector: #selector(authenticationDidChange), + name: .ironStorageAuthenticationDidChange, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(entryDidChange), + name: .ironStorageLocalStoreDidChange, + object: nil + ) + apply(detail) + startTimer() + } + + 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 { 1 } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 1 } + + override func tableView( + _ tableView: UITableView, + titleForHeaderInSection section: Int + ) -> String? { + "Apple Watch" + } + + override func tableView( + _ tableView: UITableView, + titleForFooterInSection section: Int + ) -> String? { + detail.watch.detail + } + + override func tableView( + _ tableView: UITableView, + cellForRowAt indexPath: IndexPath + ) -> UITableViewCell { + let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil) + var content = cell.defaultContentConfiguration() + content.text = "Share with Apple Watch" + content.secondaryText = "Time-based codes only" + cell.contentConfiguration = content + cell.accessoryView = watchSwitch + cell.selectionStyle = .none + return cell + } + + @objc private func authenticationDidChange() { + guard (try? authentication.state().unlocked) == true else { + lockDetail() + return + } + } + + @objc private func entryDidChange() { + refreshCode() + } + + @objc private func copyRequested() { + loadTask?.cancel() + let authentication = authentication + let path = detail.path + loadTask = Task { [weak self] in + let result = await Task.detached(priority: .userInitiated) { + do { + try authentication.touchUserActivity() + return Result.success( + try authentication.copyTotpCode( + path: path, + unixSeconds: currentUnixSeconds() + ) + ) + } catch let error as MobileAuthenticationFfiError { + return .failure(AuthenticationFailure(error)) + } catch { + return .failure(.unexpected) + } + }.value + guard !Task.isCancelled, let self else { return } + loadTask = nil + switch result { + case let .success(copy): + UIPasteboard.general.string = copy.value + copiedValue = copy.value + 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.copiedValue = nil + } + case let .failure(failure): + handle(failure) + } + } + } + + @objc private func watchSwitchChanged(_ sender: UISwitch) { + let requested = sender.isOn + sender.isEnabled = false + loadTask?.cancel() + let authentication = authentication + let path = detail.path + loadTask = Task { [weak self, weak sender] in + let result = await Task.detached(priority: .userInitiated) { + do { + try authentication.touchUserActivity() + return Result.success( + try authentication.setTotpWatchShared( + path: path, + shared: requested, + unixSeconds: currentUnixSeconds() + ) + ) + } catch let error as MobileAuthenticationFfiError { + return .failure(AuthenticationFailure(error)) + } catch { + return .failure(.unexpected) + } + }.value + guard !Task.isCancelled, let self else { return } + loadTask = nil + sender?.isEnabled = true + switch result { + case let .success(detail): + apply(detail) + UINotificationFeedbackGenerator().notificationOccurred(.success) + NotificationCenter.default.post( + name: .ironStorageWatchSnapshotDidChange, + object: nil + ) + case let .failure(failure): + sender?.setOn(!requested, animated: true) + handle(failure) + } + } + } + + private func configureHeader() { + issuerLabel.font = .preferredFont(forTextStyle: .title2) + issuerLabel.adjustsFontForContentSizeCategory = true + issuerLabel.textAlignment = .center + accountLabel.font = .preferredFont(forTextStyle: .body) + accountLabel.textColor = .secondaryLabel + accountLabel.adjustsFontForContentSizeCategory = true + accountLabel.textAlignment = .center + accountLabel.numberOfLines = 0 + codeLabel.font = UIFontMetrics(forTextStyle: .largeTitle).scaledFont( + for: .monospacedDigitSystemFont(ofSize: 48, weight: .semibold) + ) + codeLabel.adjustsFontForContentSizeCategory = true + codeLabel.textAlignment = .center + codeLabel.minimumScaleFactor = 0.55 + codeLabel.adjustsFontSizeToFitWidth = true + codeLabel.layer.cornerRadius = 12 + codeLabel.layer.masksToBounds = true + codeLabel.isAccessibilityElement = true + codeLabel.isUserInteractionEnabled = true + codeLabel.accessibilityTraits.insert(.button) + codeLabel.accessibilityHint = "Copies the current code" + countdownLabel.font = .preferredFont(forTextStyle: .footnote) + countdownLabel.textColor = .secondaryLabel + countdownLabel.adjustsFontForContentSizeCategory = true + countdownLabel.textAlignment = .center + let stack = UIStackView(arrangedSubviews: [issuerLabel, accountLabel, codeLabel, progress, countdownLabel]) + stack.axis = .vertical + stack.spacing = 12 + stack.translatesAutoresizingMaskIntoConstraints = false + codeContainer.addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: codeContainer.layoutMarginsGuide.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: codeContainer.layoutMarginsGuide.trailingAnchor), + stack.topAnchor.constraint(equalTo: codeContainer.topAnchor, constant: 20), + stack.bottomAnchor.constraint(equalTo: codeContainer.bottomAnchor, constant: -20), + ]) + codeContainer.frame = CGRect(x: 0, y: 0, width: tableView.bounds.width, height: 1) + tableView.tableHeaderView = codeContainer + codeLabel.addGestureRecognizer( + UITapGestureRecognizer(target: self, action: #selector(copyRequested)) + ) + } + + private func apply(_ detail: MobileTotpDetail) { + self.detail = detail + title = detail.issuer ?? detail.account + issuerLabel.text = detail.issuer ?? "TOTP" + accountLabel.text = detail.account + codeLabel.text = groupedCode(detail.code) + codeLabel.accessibilityLabel = "Current code \(detail.code.map(String.init).joined(separator: " "))" + watchSwitch.setOn(detail.sharedWithWatch, animated: false) + tableView.reloadData() + updateCountdown() + } + + private func startTimer() { + timerTask?.cancel() + timerTask = Task { [weak self] in + while !Task.isCancelled { + do { + try await Task.sleep(for: .seconds(1)) + } catch { + return + } + guard let self else { return } + if currentUnixSeconds() >= detail.validUntil { + refreshCode() + } else { + updateCountdown() + } + } + } + } + + private func updateCountdown() { + let remaining = detail.validUntil.saturatingSubtracting(currentUnixSeconds()) + let fraction = detail.period == 0 ? 0 : Float(remaining) / Float(detail.period) + progress.setProgress(min(max(fraction, 0), 1), animated: true) + countdownLabel.text = "\(remaining) seconds remaining" + progress.accessibilityLabel = "Code validity" + progress.accessibilityValue = countdownLabel.text + } + + private func refreshCode() { + guard loadTask == nil else { return } + let authentication = authentication + let path = detail.path + loadTask = Task { [weak self] in + let result = await Task.detached(priority: .userInitiated) { + do { + return Result.success( + try authentication.totpDetail( + path: path, + unixSeconds: currentUnixSeconds() + ) + ) + } catch let error as MobileAuthenticationFfiError { + return .failure(AuthenticationFailure(error)) + } catch { + return .failure(.unexpected) + } + }.value + guard let self else { return } + loadTask = nil + guard !Task.isCancelled else { return } + switch result { + case let .success(detail): apply(detail) + case let .failure(failure): showDetailUnavailable(failure) + } + } + } + + private func flashCopied() { + UINotificationFeedbackGenerator().notificationOccurred(.success) + UIAccessibility.post(notification: .announcement, argument: "TOTP code copied") + UIView.animate(withDuration: 0.12, animations: { + self.codeLabel.backgroundColor = .systemGreen.withAlphaComponent(0.28) + }) { _ in + UIView.animate(withDuration: 0.55) { self.codeLabel.backgroundColor = .clear } + } + } + + private func handle(_ failure: AuthenticationFailure) { + if failure.kind == .expired { + lockDetail() + } + presentAuthenticationFailure(failure) + } + + private func showDetailUnavailable(_ failure: AuthenticationFailure) { + if failure.kind == .expired { + lockDetail() + } else { + timerTask?.cancel() + detail.code = "" + codeLabel.text = nil + navigationItem.rightBarButtonItem = nil + watchSwitch.isEnabled = false + var configuration = UIContentUnavailableConfiguration.empty() + configuration.image = UIImage(systemName: "exclamationmark.triangle") + configuration.text = failure.title + configuration.secondaryText = failure.detail + configuration.button = .plain() + configuration.button.title = "Back to TOTP" + configuration.buttonProperties.primaryAction = UIAction { [weak self] _ in + self?.navigationController?.popViewController(animated: true) + } + contentUnavailableConfiguration = configuration + } + presentAuthenticationFailure(failure) + } + + private func lockDetail() { + timerTask?.cancel() + loadTask?.cancel() + clipboardTask?.cancel() + detail.code = "" + codeLabel.text = nil + if UIPasteboard.general.string == copiedValue { + UIPasteboard.general.items = [] + } + copiedValue = nil + navigationItem.rightBarButtonItem = nil + watchSwitch.isEnabled = false + var configuration = UIContentUnavailableConfiguration.empty() + configuration.image = UIImage(systemName: "lock.fill") + configuration.text = "TOTP Is Locked" + configuration.secondaryText = "The code was removed when IronStorage locked." + configuration.button = .plain() + configuration.button.title = "Back to TOTP" + configuration.buttonProperties.primaryAction = UIAction { [weak self] _ in + self?.navigationController?.popViewController(animated: true) + } + contentUnavailableConfiguration = configuration + UIAccessibility.post(notification: .announcement, argument: "TOTP locked") + } +} + +private func currentUnixSeconds() -> UInt64 { + UInt64(max(Date().timeIntervalSince1970, 0)) +} + +private func groupedCode(_ code: String) -> String { + let midpoint = code.index(code.startIndex, offsetBy: code.count / 2) + return String(code[.. UInt64 { + self > value ? self - value : 0 + } +} + @MainActor private final class PasswordDirectoryViewController: UITableViewController, MobileTabRoot { fileprivate let shellTab = MobileTab.passwords diff --git a/crates/apple/src/lib.rs b/crates/apple/src/lib.rs index a5a0c69..734d639 100644 --- a/crates/apple/src/lib.rs +++ b/crates/apple/src/lib.rs @@ -37,6 +37,11 @@ use ironstorage::{ MobilePasswordErrorKind as StoragePasswordErrorKind, MobilePasswordRowKind as StoragePasswordRowKind, }, + mobile_totp::{ + MobileTotpDetail as StorageTotpDetail, MobileTotpPage as StorageTotpPage, + MobileWatchSnapshotState as StorageWatchSnapshotState, + MobileWatchSnapshotStatus as StorageWatchSnapshotStatus, + }, }; uniffi::setup_scaffolding!(); @@ -630,6 +635,102 @@ pub struct MobileEntryCopy { pub timeout_seconds: u64, } +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum MobileWatchSnapshotState { + Unavailable, + Pending, +} + +impl From for MobileWatchSnapshotState { + fn from(state: StorageWatchSnapshotState) -> Self { + match state { + StorageWatchSnapshotState::Unavailable => Self::Unavailable, + StorageWatchSnapshotState::Pending => Self::Pending, + } + } +} + +#[derive(Clone, uniffi::Record)] +pub struct MobileWatchSnapshotStatus { + pub state: MobileWatchSnapshotState, + pub detail: String, +} + +impl From<&StorageWatchSnapshotStatus> for MobileWatchSnapshotStatus { + fn from(status: &StorageWatchSnapshotStatus) -> Self { + Self { + state: status.state().into(), + detail: status.detail().to_owned(), + } + } +} + +#[derive(Clone, uniffi::Record)] +pub struct MobileTotpRow { + pub path: String, + pub issuer: Option, + pub account: String, + pub title: String, + pub detail: String, + pub shared_with_watch: bool, +} + +#[derive(Clone, uniffi::Record)] +pub struct MobileTotpPage { + pub rows: Vec, + pub unavailable_entries: u32, + pub watch: MobileWatchSnapshotStatus, +} + +impl From for MobileTotpPage { + fn from(page: StorageTotpPage) -> Self { + Self { + rows: page + .rows() + .iter() + .map(|row| MobileTotpRow { + path: row.path().to_owned(), + issuer: row.issuer().map(str::to_owned), + account: row.account().to_owned(), + title: row.title().to_owned(), + detail: row.detail().to_owned(), + shared_with_watch: row.shared_with_watch(), + }) + .collect(), + unavailable_entries: page.unavailable_entries(), + watch: page.watch().into(), + } + } +} + +#[derive(Clone, uniffi::Record)] +pub struct MobileTotpDetail { + pub path: String, + pub issuer: Option, + pub account: String, + pub code: String, + pub valid_until: u64, + pub period: u64, + pub shared_with_watch: bool, + pub watch: MobileWatchSnapshotStatus, +} + +impl From for MobileTotpDetail { + fn from(detail: StorageTotpDetail) -> Self { + Self { + path: detail.path().to_owned(), + issuer: detail.issuer().map(str::to_owned), + account: detail.account().to_owned(), + code: String::from_utf8(detail.code().expose().to_vec()) + .expect("storage-generated TOTP codes are ASCII"), + valid_until: detail.valid_until(), + period: detail.period(), + shared_with_watch: detail.shared_with_watch(), + watch: detail.watch().into(), + } + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] pub enum MobileEntryEditorFieldKind { Password, @@ -894,6 +995,60 @@ impl MobileAuthentication { .map_err(Into::into) } + pub fn unlock_totp( + &self, + passphrase: Option, + ) -> Result { + self.authentication + .unlock_totp( + passphrase + .map(|value| ironstorage::repository::SecretBytes::new(value.into_bytes())), + ) + .map(Into::into) + .map_err(Into::into) + } + + pub fn totp_page(&self) -> Result { + self.authentication + .totp_page() + .map(Into::into) + .map_err(Into::into) + } + + pub fn totp_detail( + &self, + path: String, + unix_seconds: u64, + ) -> Result { + self.authentication + .totp_detail(&path, unix_seconds) + .map(Into::into) + .map_err(Into::into) + } + + pub fn copy_totp_code( + &self, + path: String, + unix_seconds: u64, + ) -> Result { + self.authentication + .copy_totp_code(&path, unix_seconds) + .map(Into::into) + .map_err(Into::into) + } + + pub fn set_totp_watch_shared( + &self, + path: String, + shared: bool, + unix_seconds: u64, + ) -> Result { + self.authentication + .set_totp_watch_shared(&path, shared, unix_seconds) + .map(Into::into) + .map_err(Into::into) + } + pub fn begin_create_entry( &self, directory: String, diff --git a/crates/storage/src/config.rs b/crates/storage/src/config.rs index e8f1c2c..cd0c674 100644 --- a/crates/storage/src/config.rs +++ b/crates/storage/src/config.rs @@ -19,6 +19,7 @@ use url::Url; use crate::authentication::{AuthenticationTimeout, DEFAULT_AUTHENTICATION_TIMEOUT}; use crate::mobile::MobileTab; use crate::presentation::{ClipboardTimeout, DEFAULT_CLIPBOARD_TIMEOUT}; +use crate::repository::EntryPath; const APPLICATION_DIRECTORY: &str = "ironstorage"; const CONFIG_FILE: &str = "config.toml"; @@ -38,6 +39,7 @@ pub struct Config { biometric_unlock_enabled: bool, mobile_tab: MobileTab, mobile_home_refreshed_at: Option, + watch_shared_totp_entries: BTreeSet, git_remotes: Vec, } @@ -135,6 +137,10 @@ impl Config { self.mobile_home_refreshed_at } + pub fn watch_shared_totp_entries(&self) -> &BTreeSet { + &self.watch_shared_totp_entries + } + pub fn git_remotes(&self) -> &[GitRemote] { &self.git_remotes } @@ -154,7 +160,7 @@ impl Config { } pub fn update_mobile_tab(&self, tab: MobileTab) -> Result<(), ConfigError> { - let mut document = self.document.clone(); + let mut document = self.current_document()?; let root = document .as_table_mut() .ok_or_else(|| ConfigError::Malformed { @@ -178,13 +184,46 @@ impl Config { validate_config(self.source.clone(), document, raw)?.persist() } + pub fn update_watch_shared_totp_entries( + &self, + entries: &BTreeSet, + ) -> Result<(), ConfigError> { + let mut document = self.current_document()?; + let root = document + .as_table_mut() + .ok_or_else(|| ConfigError::Malformed { + path: self.source.clone(), + })?; + let ui = root + .entry("ui") + .or_insert_with(|| toml::Value::Table(toml::Table::new())) + .as_table_mut() + .ok_or(ConfigError::InvalidField { field: "ui" })?; + ui.insert( + "watch_shared_totp_entries".to_owned(), + toml::Value::Array( + entries + .iter() + .map(|entry| toml::Value::String(entry.to_string())) + .collect(), + ), + ); + let raw = document + .clone() + .try_into::() + .map_err(|_| ConfigError::Malformed { + path: self.source.clone(), + })?; + validate_config(self.source.clone(), document, raw)?.persist() + } + pub(crate) fn update_mobile_home_refresh(&self, unix_seconds: i64) -> Result<(), ConfigError> { if unix_seconds <= 0 { return Err(ConfigError::InvalidField { field: "ui.home_remote_refreshed_at_unix_seconds", }); } - let mut document = self.document.clone(); + let mut document = self.current_document()?; let root = document .as_table_mut() .ok_or_else(|| ConfigError::Malformed { @@ -209,7 +248,7 @@ impl Config { } pub fn update_biometric_unlock(&self, enabled: bool) -> Result<(), ConfigError> { - let mut document = self.document.clone(); + let mut document = self.current_document()?; let root = document .as_table_mut() .ok_or_else(|| ConfigError::Malformed { @@ -233,6 +272,10 @@ impl Config { validate_config(self.source.clone(), document, raw)?.persist() } + fn current_document(&self) -> Result { + Self::load(Some(&self.source)).map(|config| config.document) + } + pub(crate) fn create_mobile_clone( source: PathBuf, vault: &Path, @@ -873,6 +916,8 @@ struct RawSecurity { struct RawUi { selected_mobile_tab: Option, home_remote_refreshed_at_unix_seconds: Option, + #[serde(default)] + watch_shared_totp_entries: Vec, } #[derive(Deserialize)] @@ -964,6 +1009,16 @@ fn validate_config( } None => None, }; + let watch_shared_totp_entries = raw + .ui + .watch_shared_totp_entries + .into_iter() + .map(|entry| { + EntryPath::parse(&entry).map_err(|_| ConfigError::InvalidField { + field: "ui.watch_shared_totp_entries", + }) + }) + .collect::, _>>()?; let git_remotes = validate_remotes(raw.git.remotes)?; Ok(Config { @@ -978,6 +1033,7 @@ fn validate_config( biometric_unlock_enabled, mobile_tab, mobile_home_refreshed_at, + watch_shared_totp_entries, git_remotes, }) } @@ -1162,6 +1218,7 @@ fn validate_known_fields(value: &toml::Value, source: &Path) -> Result<(), Confi &[ "selected_mobile_tab", "home_remote_refreshed_at_unix_seconds", + "watch_shared_totp_entries", ], )?; } diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index fe27ca9..9a6ee5d 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -20,6 +20,7 @@ pub mod mobile_entry; pub mod mobile_home; pub mod mobile_onboarding; pub mod mobile_passwords; +pub mod mobile_totp; pub mod mutation; pub mod otp; pub mod presentation; diff --git a/crates/storage/src/mobile_authentication.rs b/crates/storage/src/mobile_authentication.rs index 1e36ba6..bb9b6cf 100644 --- a/crates/storage/src/mobile_authentication.rs +++ b/crates/storage/src/mobile_authentication.rs @@ -15,6 +15,7 @@ use crate::{ MobileEntryEditorInput, MobileEntryEditorPage, MobileEntryEditorSession, MobileEntryPage, MobileEntryValueError, field_value, }, + mobile_totp::{MobileTotpDetail, MobileTotpError, MobileTotpPage, MobileTotpService}, recipient::RecipientPolicyManager, repository::{ DirectoryPath, EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes, @@ -161,6 +162,7 @@ struct MobileAuthenticationStatus { active: Option, next_editor_id: u64, editors: BTreeMap, + watch_shared_totp_entries: std::collections::BTreeSet, } /// One process-wide mobile authentication lease shared by every tab and viewer. @@ -194,6 +196,7 @@ impl MobileAuthentication { active: None, next_editor_id: 0, editors: BTreeMap::new(), + watch_shared_totp_entries: config.watch_shared_totp_entries().clone(), }), config, repository, @@ -396,6 +399,95 @@ impl MobileAuthentication { }) } + pub fn unlock_totp( + &self, + passphrase: Option, + ) -> Result { + let path = self + .repository + .snapshot() + .map_err(entry_error)? + .entries() + .next() + .map(|entry| entry.path().clone()) + .ok_or_else(|| entry_detail("TOTP Is Unavailable", "the password store is empty"))?; + let ciphertext = self.repository.read_entry(&path).map_err(entry_error)?; + self.unlock_ciphertext(&ciphertext, passphrase) + } + + pub fn totp_page(&self) -> Result { + self.ensure_active()?; + let (handle, key, shared) = { + let status = self.status()?; + let active = status.active.as_ref().ok_or_else(locked_error)?; + ( + active.handle.clone(), + active.key.clone(), + status.watch_shared_totp_entries.clone(), + ) + }; + let mut provider = KeyOnlyProvider::new(handle, &key); + MobileTotpService::new(&self.repository, &self.keys) + .page(&shared, &mut provider) + .map_err(totp_error) + } + + pub fn totp_detail( + &self, + path: &str, + unix_seconds: u64, + ) -> Result { + self.ensure_active()?; + let (handle, key, shared) = { + let status = self.status()?; + let active = status.active.as_ref().ok_or_else(locked_error)?; + ( + active.handle.clone(), + active.key.clone(), + status.watch_shared_totp_entries.clone(), + ) + }; + let mut provider = KeyOnlyProvider::new(handle, &key); + MobileTotpService::new(&self.repository, &self.keys) + .detail(path, unix_seconds, &shared, &mut provider) + .map_err(totp_error) + } + + pub fn copy_totp_code( + &self, + path: &str, + unix_seconds: u64, + ) -> Result { + let detail = self.totp_detail(path, unix_seconds)?; + let value = String::from_utf8(detail.code().expose().to_vec()) + .map_err(|_| entry_detail("TOTP Code Is Unavailable", "the code is not UTF-8"))?; + Ok(MobileEntryCopy { + value, + timeout_seconds: self.config.clipboard_timeout().duration().as_secs(), + }) + } + + pub fn set_totp_watch_shared( + &self, + path: &str, + shared: bool, + unix_seconds: u64, + ) -> Result { + self.totp_detail(path, unix_seconds)?; + let entry = EntryPath::parse(path).map_err(entry_error)?; + let mut selected = self.status()?.watch_shared_totp_entries.clone(); + if shared { + selected.insert(entry); + } else { + selected.remove(&entry); + } + self.config + .update_watch_shared_totp_entries(&selected) + .map_err(config_error)?; + self.status()?.watch_shared_totp_entries = selected; + self.totp_detail(path, unix_seconds) + } + pub fn replace_entry_field( &self, path: &str, @@ -786,6 +878,10 @@ fn editor_error(error: MobileEntryEditorError) -> MobileAuthenticationError { entry_detail("Entry Draft Is Invalid", error) } +fn totp_error(error: MobileTotpError) -> MobileAuthenticationError { + entry_detail("TOTP Is Unavailable", error) +} + fn editor_missing() -> MobileAuthenticationError { entry_detail( "Entry Draft Is Unavailable", diff --git a/crates/storage/src/mobile_totp.rs b/crates/storage/src/mobile_totp.rs new file mode 100644 index 0000000..06f6eea --- /dev/null +++ b/crates/storage/src/mobile_totp.rs @@ -0,0 +1,301 @@ +//! Storage-owned TOTP catalog and detail state for native mobile frontends. + +use std::{collections::BTreeSet, error::Error, fmt}; + +use crate::{ + crypto::{KeyStore, SecretProvider}, + otp::{OtpError, OtpKind, OtpService}, + repository::{EntryPath, Repository, RepositoryError, SecretBytes}, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MobileWatchSnapshotState { + Unavailable, + Pending, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MobileWatchSnapshotStatus { + state: MobileWatchSnapshotState, + detail: String, +} + +impl MobileWatchSnapshotStatus { + pub fn state(&self) -> MobileWatchSnapshotState { + self.state + } + + pub fn detail(&self) -> &str { + &self.detail + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MobileTotpRow { + path: String, + issuer: Option, + account: String, + title: String, + detail: String, + shared_with_watch: bool, +} + +impl MobileTotpRow { + pub fn path(&self) -> &str { + &self.path + } + + pub fn issuer(&self) -> Option<&str> { + self.issuer.as_deref() + } + + pub fn account(&self) -> &str { + &self.account + } + + pub fn title(&self) -> &str { + &self.title + } + + pub fn detail(&self) -> &str { + &self.detail + } + + pub fn shared_with_watch(&self) -> bool { + self.shared_with_watch + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MobileTotpPage { + rows: Vec, + unavailable_entries: u32, + watch: MobileWatchSnapshotStatus, +} + +impl MobileTotpPage { + pub fn rows(&self) -> &[MobileTotpRow] { + &self.rows + } + + pub fn unavailable_entries(&self) -> u32 { + self.unavailable_entries + } + + pub fn watch(&self) -> &MobileWatchSnapshotStatus { + &self.watch + } +} + +pub struct MobileTotpDetail { + path: String, + issuer: Option, + account: String, + code: SecretBytes, + valid_until: u64, + period: u64, + shared_with_watch: bool, + watch: MobileWatchSnapshotStatus, +} + +impl MobileTotpDetail { + pub fn path(&self) -> &str { + &self.path + } + + pub fn issuer(&self) -> Option<&str> { + self.issuer.as_deref() + } + + pub fn account(&self) -> &str { + &self.account + } + + pub fn code(&self) -> &SecretBytes { + &self.code + } + + pub fn valid_until(&self) -> u64 { + self.valid_until + } + + pub fn period(&self) -> u64 { + self.period + } + + pub fn shared_with_watch(&self) -> bool { + self.shared_with_watch + } + + pub fn watch(&self) -> &MobileWatchSnapshotStatus { + &self.watch + } +} + +impl fmt::Debug for MobileTotpDetail { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MobileTotpDetail") + .field("path", &self.path) + .field("issuer", &self.issuer) + .field("account", &self.account) + .field("code", &"[REDACTED]") + .field("valid_until", &self.valid_until) + .field("period", &self.period) + .field("shared_with_watch", &self.shared_with_watch) + .field("watch", &self.watch) + .finish() + } +} + +pub struct MobileTotpService<'a> { + repository: &'a Repository, + keys: &'a KeyStore, +} + +impl<'a> MobileTotpService<'a> { + pub fn new(repository: &'a Repository, keys: &'a KeyStore) -> Self { + Self { repository, keys } + } + + pub fn page( + &self, + shared: &BTreeSet, + provider: &mut impl SecretProvider, + ) -> Result { + let snapshot = self.repository.snapshot()?; + let mut rows = Vec::new(); + let mut unavailable_entries = 0_u32; + for entry in snapshot.entries() { + match OtpService::new(self.repository, self.keys) + .uri(&entry.path().to_string(), provider) + { + Ok(uri) if uri.kind() == OtpKind::Totp => rows.push(row( + entry.path(), + uri.issuer(), + uri.account(), + shared.contains(entry.path()), + )), + Ok(_) | Err(OtpError::MissingUri { .. } | OtpError::AmbiguousUri { .. }) => {} + Err(OtpError::Crypto(_)) => { + unavailable_entries = unavailable_entries.saturating_add(1); + } + Err(OtpError::Repository(error)) => return Err(error.into()), + 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)) + }); + let selected = rows.iter().filter(|row| row.shared_with_watch).count(); + Ok(MobileTotpPage { + rows, + unavailable_entries, + watch: snapshot_status(selected), + }) + } + + pub fn detail( + &self, + entry: &str, + unix_seconds: u64, + shared: &BTreeSet, + provider: &mut impl SecretProvider, + ) -> Result { + let path = EntryPath::parse(entry)?; + let uri = OtpService::new(self.repository, self.keys).uri(entry, provider)?; + if uri.kind() != OtpKind::Totp { + return Err(OtpError::NotTotp.into()); + } + let period = uri.period().ok_or(OtpError::NotTotp)?; + let valid_until = (unix_seconds / period) + .checked_add(1) + .and_then(|counter| counter.checked_mul(period)) + .ok_or(OtpError::CounterOverflow)?; + let shared_with_watch = shared.contains(&path); + Ok(MobileTotpDetail { + path: path.to_string(), + issuer: uri.issuer().map(str::to_owned), + account: uri.account().to_owned(), + code: uri.code_at(unix_seconds)?, + valid_until, + period, + shared_with_watch, + watch: snapshot_status(shared.len()), + }) + } +} + +fn row( + path: &EntryPath, + issuer: Option<&str>, + account: &str, + shared_with_watch: bool, +) -> MobileTotpRow { + MobileTotpRow { + path: path.to_string(), + issuer: issuer.map(str::to_owned), + account: account.to_owned(), + title: issuer.unwrap_or(account).to_owned(), + detail: issuer.map_or_else(|| path.to_string(), |_| account.to_owned()), + shared_with_watch, + } +} + +fn snapshot_status(selected: usize) -> MobileWatchSnapshotStatus { + if selected == 0 { + MobileWatchSnapshotStatus { + state: MobileWatchSnapshotState::Unavailable, + detail: "No TOTP codes are selected for Apple Watch.".to_owned(), + } + } else { + MobileWatchSnapshotStatus { + state: MobileWatchSnapshotState::Pending, + detail: format!( + "{selected} selected TOTP {} pending Apple Watch synchronization.", + if selected == 1 { + "code is" + } else { + "codes are" + } + ), + } + } +} + +#[derive(Debug)] +pub enum MobileTotpError { + Repository(RepositoryError), + Otp(OtpError), +} + +impl fmt::Display for MobileTotpError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Repository(error) => error.fmt(formatter), + Self::Otp(error) => error.fmt(formatter), + } + } +} + +impl Error for MobileTotpError {} + +impl From for MobileTotpError { + fn from(error: RepositoryError) -> Self { + Self::Repository(error) + } +} + +impl From for MobileTotpError { + fn from(error: OtpError) -> Self { + Self::Otp(error) + } +} diff --git a/crates/storage/tests/config_contract.rs b/crates/storage/tests/config_contract.rs index 91d1a8a..27b1ec5 100644 --- a/crates/storage/tests/config_contract.rs +++ b/crates/storage/tests/config_contract.rs @@ -1,6 +1,6 @@ #![forbid(unsafe_code)] -use std::{error::Error, ffi::OsStr, fs, path::Path, time::Duration}; +use std::{collections::BTreeSet, error::Error, ffi::OsStr, fs, path::Path, time::Duration}; use ironstorage::presentation::DEFAULT_CLIPBOARD_TIMEOUT; use ironstorage::{ @@ -8,6 +8,7 @@ use ironstorage::{ config::{ConfigError, ConfigLoader, EditorSource}, desktop::DesktopStorage, mobile::MobileTab, + repository::EntryPath, }; use tempfile::TempDir; @@ -64,7 +65,6 @@ fn explicit_relative_configuration_resolves_deterministically() -> TestResult { let config = fixture .loader() .load(Some(Path::new("config/config.toml")))?; - assert_eq!(config.source(), fs::canonicalize(fixture.explicit_path())?); assert_eq!( config.vault(), @@ -98,6 +98,33 @@ fn explicit_relative_configuration_resolves_deterministically() -> TestResult { Ok(()) } +#[test] +fn watch_totp_selection_persists_only_in_application_configuration() -> TestResult { + let fixture = ConfigurationFixture::new()?; + fixture.write_explicit(fixture.valid_contents())?; + let config = fixture + .loader() + .load(Some(Path::new("config/config.toml")))?; + let stale = fixture + .loader() + .load(Some(Path::new("config/config.toml")))?; + let selected = BTreeSet::from([ + EntryPath::parse("otp/personal")?, + EntryPath::parse("otp/work")?, + ]); + + config.update_watch_shared_totp_entries(&selected)?; + stale.update_mobile_tab(MobileTab::Totp)?; + let reloaded = fixture + .loader() + .load(Some(Path::new("config/config.toml")))?; + + assert_eq!(reloaded.watch_shared_totp_entries(), &selected); + assert_eq!(reloaded.mobile_tab(), MobileTab::Totp); + assert!(!fixture.temporary.path().join("cwd/vault").exists()); + Ok(()) +} + #[test] fn authentication_timeout_defaults_overrides_and_rejects_invalid_values() -> TestResult { let fixture = ConfigurationFixture::new()?; diff --git a/crates/storage/tests/mobile_totp.rs b/crates/storage/tests/mobile_totp.rs new file mode 100644 index 0000000..f277957 --- /dev/null +++ b/crates/storage/tests/mobile_totp.rs @@ -0,0 +1,117 @@ +#![forbid(unsafe_code)] + +mod support; + +use std::collections::{BTreeMap, BTreeSet}; + +use ironstorage::{ + crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError}, + mobile_totp::{MobileTotpService, MobileWatchSnapshotState}, + recipient::RecipientPolicyManager, + repository::{EntryPath, Repository, SecretBytes}, +}; +use support::compatibility::{FixtureSet, TestResult}; + +struct FixtureSecrets(BTreeMap>); + +impl FixtureSecrets { + fn all(fixture: &FixtureSet) -> Self { + Self( + fixture + .generated + .keys + .iter() + .map(|key| { + ( + key.primary_fingerprint.clone(), + key.passphrase.as_bytes().to_vec(), + ) + }) + .collect(), + ) + } +} + +impl SecretProvider for FixtureSecrets { + fn secret_for(&mut self, key: &KeyInfo) -> Result { + self.0 + .get(key.fingerprint().as_str()) + .cloned() + .map(SecretBytes::new) + .ok_or(SecretProviderError::Unavailable) + } +} + +#[test] +fn mobile_totp_catalog_details_and_watch_selection_are_storage_owned() -> 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&digits=8&period=30\n", + )?; + write_plaintext( + &repository, + &keys, + "otp/counter", + b"otpauth://hotp/Counter?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&counter=0\n", + )?; + write_plaintext(&repository, &keys, "ordinary", b"password\nlogin: alice\n")?; + + let service = MobileTotpService::new(&repository, &keys); + let mut secrets = FixtureSecrets::all(&fixture); + let page = service.page(&BTreeSet::new(), &mut secrets)?; + let alice = page + .rows() + .iter() + .find(|row| row.path() == "otp/alice") + .expect("inserted TOTP row"); + assert_eq!(alice.issuer(), Some("Acme")); + assert_eq!(alice.account(), "alice@example.com"); + assert!(!alice.shared_with_watch()); + assert!(page.rows().iter().all(|row| row.path() != "otp/counter")); + assert_eq!(page.watch().state(), MobileWatchSnapshotState::Unavailable); + assert!(!format!("{page:?}").contains("94287082")); + + let detail = service.detail("otp/alice", 59, &BTreeSet::new(), &mut secrets)?; + assert_eq!(detail.code().expose(), b"94287082"); + assert_eq!(detail.valid_until(), 60); + assert_eq!(detail.period(), 30); + assert!(!format!("{detail:?}").contains("94287082")); + + let selected = BTreeSet::from([EntryPath::parse("otp/alice")?]); + let page = service.page(&selected, &mut secrets)?; + assert!( + page.rows() + .iter() + .find(|row| row.path() == "otp/alice") + .expect("selected TOTP row") + .shared_with_watch() + ); + assert_eq!(page.watch().state(), MobileWatchSnapshotState::Pending); + let detail = service.detail("otp/alice", 60, &selected, &mut secrets)?; + assert!(detail.shared_with_watch()); + assert_eq!(detail.valid_until(), 90); + Ok(()) +} + +fn write_plaintext( + repository: &Repository, + keys: &KeyStore, + path: &str, + plaintext: &[u8], +) -> TestResult { + let path = EntryPath::parse(path)?; + let recipients = + RecipientPolicyManager::new(repository, keys).resolve_for_entry(&path, None)?; + let encrypted = keys.encrypt( + SecretBytes::new(plaintext.to_vec()), + recipients.recipients(), + )?; + repository.write_entry(&path, &encrypted)?; + Ok(()) +}