diff --git a/apple/Generated/ironstorage_apple.swift b/apple/Generated/ironstorage_apple.swift
index 1589a27..189bc8e 100644
--- a/apple/Generated/ironstorage_apple.swift
+++ b/apple/Generated/ironstorage_apple.swift
@@ -478,6 +478,22 @@ fileprivate struct FfiConverterUInt32: FfiConverterPrimitive {
}
}
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+fileprivate struct FfiConverterUInt64: FfiConverterPrimitive {
+ typealias FfiType = UInt64
+ typealias SwiftType = UInt64
+
+ public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UInt64 {
+ return try lift(readInt(&buf))
+ }
+
+ public static func write(_ value: SwiftType, into buf: inout [UInt8]) {
+ writeInt(&buf, lower(value))
+ }
+}
+
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
@@ -567,6 +583,178 @@ fileprivate struct FfiConverterString: FfiConverter {
+public protocol MobileAuthenticationProtocol: AnyObject, Sendable {
+
+ func cancel() throws
+
+ func manualLock() throws
+
+ func setBiometricUnlock(enabled: Bool) throws -> MobileAuthenticationState
+
+ func state() throws -> MobileAuthenticationState
+
+ func touchUserActivity() throws
+
+ func unlockEntry(path: String, passphrase: String?) throws -> MobileAuthenticationState
+
+}
+open class MobileAuthentication: MobileAuthenticationProtocol, @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_mobileauthentication(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_mobileauthentication(handle, $0) }
+ }
+
+
+
+
+open func cancel()throws {try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
+ uniffiCallStatus in
+ uniffi_ironstorage_apple_fn_method_mobileauthentication_cancel(
+ self.uniffiCloneHandle(),uniffiCallStatus
+ )
+}
+}
+
+open func manualLock()throws {try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
+ uniffiCallStatus in
+ uniffi_ironstorage_apple_fn_method_mobileauthentication_manual_lock(
+ self.uniffiCloneHandle(),uniffiCallStatus
+ )
+}
+}
+
+open func setBiometricUnlock(enabled: Bool)throws -> MobileAuthenticationState {
+ return try FfiConverterTypeMobileAuthenticationState_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
+ uniffiCallStatus in
+ uniffi_ironstorage_apple_fn_method_mobileauthentication_set_biometric_unlock(
+ self.uniffiCloneHandle(),
+ FfiConverterBool.lower(enabled),uniffiCallStatus
+ )
+})
+}
+
+open func state()throws -> MobileAuthenticationState {
+ return try FfiConverterTypeMobileAuthenticationState_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
+ uniffiCallStatus in
+ uniffi_ironstorage_apple_fn_method_mobileauthentication_state(
+ self.uniffiCloneHandle(),uniffiCallStatus
+ )
+})
+}
+
+open func touchUserActivity()throws {try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
+ uniffiCallStatus in
+ uniffi_ironstorage_apple_fn_method_mobileauthentication_touch_user_activity(
+ self.uniffiCloneHandle(),uniffiCallStatus
+ )
+}
+}
+
+open func unlockEntry(path: String, passphrase: String?)throws -> MobileAuthenticationState {
+ return try FfiConverterTypeMobileAuthenticationState_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
+ uniffiCallStatus in
+ uniffi_ironstorage_apple_fn_method_mobileauthentication_unlock_entry(
+ self.uniffiCloneHandle(),
+ FfiConverterString.lower(path),
+ FfiConverterOptionString.lower(passphrase),uniffiCallStatus
+ )
+})
+}
+
+
+
+}
+
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public struct FfiConverterTypeMobileAuthentication: FfiConverter {
+ typealias FfiType = UInt64
+ typealias SwiftType = MobileAuthentication
+
+ public static func lift(_ handle: UInt64) throws -> MobileAuthentication {
+ return MobileAuthentication(unsafeFromHandle: handle)
+ }
+
+ public static func lower(_ value: MobileAuthentication) -> UInt64 {
+ return value.uniffiCloneHandle()
+ }
+
+ public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileAuthentication {
+ let handle: UInt64 = try readInt(&buf)
+ return try lift(handle)
+ }
+
+ public static func write(_ value: MobileAuthentication, into buf: inout [UInt8]) {
+ writeInt(&buf, lower(value))
+ }
+}
+
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public func FfiConverterTypeMobileAuthentication_lift(_ handle: UInt64) throws -> MobileAuthentication {
+ return try FfiConverterTypeMobileAuthentication.lift(handle)
+}
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public func FfiConverterTypeMobileAuthentication_lower(_ value: MobileAuthentication) -> UInt64 {
+ return FfiConverterTypeMobileAuthentication.lower(value)
+}
+
+
+
+
+
+
public protocol MobileHomeOperationProtocol: AnyObject, Sendable {
func cached() throws -> MobileHomePage
@@ -887,6 +1075,64 @@ public func FfiConverterTypeMobileOnboardingOperation_lower(_ value: MobileOnboa
+public struct MobileAuthenticationState: Equatable, Hashable {
+ public var unlocked: Bool
+ public var biometricUnlockEnabled: Bool
+ public var remainingSeconds: UInt64
+
+ // Default memberwise initializers are never public by default, so we
+ // declare one manually.
+ public init(unlocked: Bool, biometricUnlockEnabled: Bool, remainingSeconds: UInt64) {
+ self.unlocked = unlocked
+ self.biometricUnlockEnabled = biometricUnlockEnabled
+ self.remainingSeconds = remainingSeconds
+ }
+
+
+
+
+}
+
+#if compiler(>=6)
+extension MobileAuthenticationState: Sendable {}
+#endif
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public struct FfiConverterTypeMobileAuthenticationState: FfiConverterRustBuffer {
+ public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileAuthenticationState {
+ return
+ try MobileAuthenticationState(
+ unlocked: FfiConverterBool.read(from: &buf),
+ biometricUnlockEnabled: FfiConverterBool.read(from: &buf),
+ remainingSeconds: FfiConverterUInt64.read(from: &buf)
+ )
+ }
+
+ public static func write(_ value: MobileAuthenticationState, into buf: inout [UInt8]) {
+ FfiConverterBool.write(value.unlocked, into: &buf)
+ FfiConverterBool.write(value.biometricUnlockEnabled, into: &buf)
+ FfiConverterUInt64.write(value.remainingSeconds, into: &buf)
+ }
+}
+
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public func FfiConverterTypeMobileAuthenticationState_lift(_ buf: RustBuffer) throws -> MobileAuthenticationState {
+ return try FfiConverterTypeMobileAuthenticationState.lift(buf)
+}
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public func FfiConverterTypeMobileAuthenticationState_lower(_ value: MobileAuthenticationState) -> RustBuffer {
+ return FfiConverterTypeMobileAuthenticationState.lower(value)
+}
+
+
public struct MobileHomeChange: Equatable, Hashable {
public var id: String
public var title: String
@@ -1710,6 +1956,200 @@ public func FfiConverterTypeMobileShell_lower(_ value: MobileShell) -> RustBuffe
+public enum MobileAuthenticationErrorKind: Equatable, Hashable {
+
+ case passphraseRequired
+ case invalidPassphrase
+ case cancelled
+ case biometryUnavailable
+ case configuration
+ case keyMaterial
+ case entry
+ case secureStorage
+ case expired
+
+
+
+
+
+}
+
+#if compiler(>=6)
+extension MobileAuthenticationErrorKind: Sendable {}
+#endif
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public struct FfiConverterTypeMobileAuthenticationErrorKind: FfiConverterRustBuffer {
+ typealias SwiftType = MobileAuthenticationErrorKind
+
+ public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileAuthenticationErrorKind {
+ let variant: Int32 = try readInt(&buf)
+ switch variant {
+
+ case 1: return .passphraseRequired
+
+ case 2: return .invalidPassphrase
+
+ case 3: return .cancelled
+
+ case 4: return .biometryUnavailable
+
+ case 5: return .configuration
+
+ case 6: return .keyMaterial
+
+ case 7: return .entry
+
+ case 8: return .secureStorage
+
+ case 9: return .expired
+
+ default: throw UniffiInternalError.unexpectedEnumCase
+ }
+ }
+
+ public static func write(_ value: MobileAuthenticationErrorKind, into buf: inout [UInt8]) {
+ switch value {
+
+
+ case .passphraseRequired:
+ writeInt(&buf, Int32(1))
+
+
+ case .invalidPassphrase:
+ writeInt(&buf, Int32(2))
+
+
+ case .cancelled:
+ writeInt(&buf, Int32(3))
+
+
+ case .biometryUnavailable:
+ writeInt(&buf, Int32(4))
+
+
+ case .configuration:
+ writeInt(&buf, Int32(5))
+
+
+ case .keyMaterial:
+ writeInt(&buf, Int32(6))
+
+
+ case .entry:
+ writeInt(&buf, Int32(7))
+
+
+ case .secureStorage:
+ writeInt(&buf, Int32(8))
+
+
+ case .expired:
+ writeInt(&buf, Int32(9))
+
+ }
+ }
+}
+
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public func FfiConverterTypeMobileAuthenticationErrorKind_lift(_ buf: RustBuffer) throws -> MobileAuthenticationErrorKind {
+ return try FfiConverterTypeMobileAuthenticationErrorKind.lift(buf)
+}
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public func FfiConverterTypeMobileAuthenticationErrorKind_lower(_ value: MobileAuthenticationErrorKind) -> RustBuffer {
+ return FfiConverterTypeMobileAuthenticationErrorKind.lower(value)
+}
+
+
+
+public
+enum MobileAuthenticationFfiError: Swift.Error, Equatable, Hashable, Foundation.LocalizedError {
+
+
+
+ case Failed(kind: MobileAuthenticationErrorKind, title: String, detail: String
+ )
+
+
+
+
+
+
+ public var errorDescription: String? {
+ String(reflecting: self)
+ }
+
+}
+
+#if compiler(>=6)
+extension MobileAuthenticationFfiError: Sendable {}
+#endif
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public struct FfiConverterTypeMobileAuthenticationFfiError: FfiConverterRustBuffer {
+ typealias SwiftType = MobileAuthenticationFfiError
+
+ public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileAuthenticationFfiError {
+ let variant: Int32 = try readInt(&buf)
+ switch variant {
+
+
+
+
+ case 1: return .Failed(
+ kind: try FfiConverterTypeMobileAuthenticationErrorKind.read(from: &buf),
+ title: try FfiConverterString.read(from: &buf),
+ detail: try FfiConverterString.read(from: &buf)
+ )
+
+ default: throw UniffiInternalError.unexpectedEnumCase
+ }
+ }
+
+ public static func write(_ value: MobileAuthenticationFfiError, into buf: inout [UInt8]) {
+ switch value {
+
+
+
+
+
+ case let .Failed(kind,title,detail):
+ writeInt(&buf, Int32(1))
+ FfiConverterTypeMobileAuthenticationErrorKind.write(kind, into: &buf)
+ FfiConverterString.write(title, into: &buf)
+ FfiConverterString.write(detail, into: &buf)
+
+ }
+ }
+}
+
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public func FfiConverterTypeMobileAuthenticationFfiError_lift(_ buf: RustBuffer) throws -> MobileAuthenticationFfiError {
+ return try FfiConverterTypeMobileAuthenticationFfiError.lift(buf)
+}
+
+#if swift(>=5.8)
+@_documentation(visibility: private)
+#endif
+public func FfiConverterTypeMobileAuthenticationFfiError_lower(_ value: MobileAuthenticationFfiError) -> RustBuffer {
+ return FfiConverterTypeMobileAuthenticationFfiError.lower(value)
+}
+
+
+
public enum MobileHomeChangeKind: Equatable, Hashable {
case passwordEntry
@@ -3198,6 +3638,13 @@ fileprivate struct FfiConverterSequenceTypeMobilePasswordRow: FfiConverterRustBu
return seq
}
}
+public func mobileAuthentication()throws -> MobileAuthentication {
+ return try FfiConverterTypeMobileAuthentication_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
+ uniffiCallStatus in
+ uniffi_ironstorage_apple_fn_func_mobile_authentication(uniffiCallStatus
+ )
+})
+}
public func mobileHomeOperation() -> MobileHomeOperation {
return try! FfiConverterTypeMobileHomeOperation_lift(try! rustCall() {
uniffiCallStatus in
@@ -3277,6 +3724,9 @@ private let initializationResult: InitializationResult = {
if bindings_contract_version != scaffolding_contract_version {
return InitializationResult.contractVersionMismatch
}
+ if (uniffi_ironstorage_apple_checksum_func_mobile_authentication() != 38258) {
+ return InitializationResult.apiChecksumMismatch
+ }
if (uniffi_ironstorage_apple_checksum_func_mobile_home_operation() != 10595) {
return InitializationResult.apiChecksumMismatch
}
@@ -3301,6 +3751,24 @@ private let initializationResult: InitializationResult = {
if (uniffi_ironstorage_apple_checksum_func_set_selected_mobile_tab() != 65280) {
return InitializationResult.apiChecksumMismatch
}
+ if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_cancel() != 6512) {
+ return InitializationResult.apiChecksumMismatch
+ }
+ if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_manual_lock() != 57220) {
+ return InitializationResult.apiChecksumMismatch
+ }
+ if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_biometric_unlock() != 9486) {
+ return InitializationResult.apiChecksumMismatch
+ }
+ if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_state() != 60826) {
+ 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_mobilehomeoperation_cached() != 32436) {
return InitializationResult.apiChecksumMismatch
}
diff --git a/apple/Generated/ironstorage_appleFFI.h b/apple/Generated/ironstorage_appleFFI.h
index 4f85b1d..c7c0c31 100644
--- a/apple/Generated/ironstorage_appleFFI.h
+++ b/apple/Generated/ironstorage_appleFFI.h
@@ -242,6 +242,46 @@ typedef struct UniffiForeignFutureResultVoid {
typedef void (*UniffiForeignFutureCompleteVoid)(uint64_t, UniffiForeignFutureResultVoid
);
+#endif
+#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEAUTHENTICATION
+#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEAUTHENTICATION
+uint64_t uniffi_ironstorage_apple_fn_clone_mobileauthentication(uint64_t handle, RustCallStatus *_Nonnull out_status
+);
+#endif
+#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FREE_MOBILEAUTHENTICATION
+#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FREE_MOBILEAUTHENTICATION
+void uniffi_ironstorage_apple_fn_free_mobileauthentication(uint64_t handle, 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
+);
+#endif
+#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_MANUAL_LOCK
+#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_MANUAL_LOCK
+void uniffi_ironstorage_apple_fn_method_mobileauthentication_manual_lock(uint64_t ptr, RustCallStatus *_Nonnull out_status
+);
+#endif
+#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK
+#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK
+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_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_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
+);
+#endif
+#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_UNLOCK_ENTRY
+#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_UNLOCK_ENTRY
+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_CLONE_MOBILEHOMEOPERATION
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEHOMEOPERATION
@@ -311,6 +351,12 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobileonboardingoperation_progress
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_SETUP
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_SETUP
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_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
+
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_HOME_OPERATION
@@ -614,6 +660,12 @@ void ffi_ironstorage_apple_rust_future_free_void(uint64_t handle
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_APPLE_RUST_FUTURE_COMPLETE_VOID
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_APPLE_RUST_FUTURE_COMPLETE_VOID
void ffi_ironstorage_apple_rust_future_complete_void(uint64_t handle, RustCallStatus *_Nonnull out_status
+);
+#endif
+#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_AUTHENTICATION
+#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_AUTHENTICATION
+uint16_t uniffi_ironstorage_apple_checksum_func_mobile_authentication(void
+
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_HOME_OPERATION
@@ -662,6 +714,42 @@ uint16_t uniffi_ironstorage_apple_checksum_func_replace_configured_mobile_applic
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_SET_SELECTED_MOBILE_TAB
uint16_t uniffi_ironstorage_apple_checksum_func_set_selected_mobile_tab(void
+);
+#endif
+#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_CANCEL
+#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_CANCEL
+uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_cancel(void
+
+);
+#endif
+#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_MANUAL_LOCK
+#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_MANUAL_LOCK
+uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_manual_lock(void
+
+);
+#endif
+#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK
+#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_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_TOUCH_USER_ACTIVITY
+#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_TOUCH_USER_ACTIVITY
+uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_touch_user_activity(void
+
+);
+#endif
+#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_UNLOCK_ENTRY
+#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_MOBILEHOMEOPERATION_CACHED
diff --git a/apple/Info.plist b/apple/Info.plist
index 2ba1053..df4f349 100644
--- a/apple/Info.plist
+++ b/apple/Info.plist
@@ -22,6 +22,8 @@
1
ITSAppUsesNonExemptEncryption
+ NSFaceIDUsageDescription
+ Unlock your GPG key for protected password operations.
UILaunchScreen
UISupportedInterfaceOrientations
diff --git a/apple/Sources/App/IronStorageApp.swift b/apple/Sources/App/IronStorageApp.swift
index 151ade1..7bf6cb1 100644
--- a/apple/Sources/App/IronStorageApp.swift
+++ b/apple/Sources/App/IronStorageApp.swift
@@ -4,6 +4,9 @@ extension Notification.Name {
static let ironStorageLocalStoreDidChange = Notification.Name(
"de.rfc1437.ironstorage.local-store-did-change"
)
+ static let ironStorageAuthenticationDidChange = Notification.Name(
+ "de.rfc1437.ironstorage.authentication-did-change"
+ )
}
@main
@@ -33,15 +36,27 @@ private protocol MobileTabRoot: AnyObject {
@MainActor
private final class AppContext: NSObject, UITabBarControllerDelegate {
private let tabs = UITabBarController()
+ private let authentication = try? mobileAuthentication()
private var navigationControllers: [UINavigationController] = []
private var restoreTask: Task?
+ private var authenticationMonitor: Task?
+
+ deinit {
+ restoreTask?.cancel()
+ authenticationMonitor?.cancel()
+ }
func makeRootController() -> UIViewController {
let shell = mobileShellFixture(state: .loading)
navigationControllers = shell.pages.map { page in
- let root: UIViewController = page.tab == .passwords
- ? PasswordDirectoryViewController(shellPage: page)
- : ShellViewController(page: page)
+ let root: UIViewController = switch page.tab {
+ case .passwords:
+ PasswordDirectoryViewController(shellPage: page, authentication: authentication)
+ case .preferences:
+ PreferencesViewController(page: page, authentication: authentication)
+ default:
+ ShellViewController(page: page)
+ }
let navigation = UINavigationController(rootViewController: root)
navigation.navigationBar.prefersLargeTitles = true
navigation.tabBarItem = UITabBarItem(
@@ -54,6 +69,7 @@ private final class AppContext: NSObject, UITabBarControllerDelegate {
tabs.viewControllers = navigationControllers
tabs.delegate = self
restoreSelectedTab()
+ monitorAuthentication()
return tabs
}
@@ -90,6 +106,29 @@ private final class AppContext: NSObject, UITabBarControllerDelegate {
}
}
}
+
+ private func monitorAuthentication() {
+ authenticationMonitor?.cancel()
+ guard let authentication else { return }
+ authenticationMonitor = Task {
+ var wasUnlocked = (try? authentication.state().unlocked) ?? false
+ while !Task.isCancelled {
+ do {
+ try await Task.sleep(for: .seconds(1))
+ } catch {
+ return
+ }
+ let isUnlocked = (try? authentication.state().unlocked) ?? false
+ if isUnlocked != wasUnlocked {
+ wasUnlocked = isUnlocked
+ NotificationCenter.default.post(
+ name: .ironStorageAuthenticationDidChange,
+ object: authentication
+ )
+ }
+ }
+ }
+ }
}
@MainActor
@@ -614,17 +653,245 @@ private final class ShellViewController: UITableViewController, MobileTabRoot {
}
}
+@MainActor
+private final class PreferencesViewController: UITableViewController, MobileTabRoot {
+ fileprivate let shellTab = MobileTab.preferences
+ private var page: MobilePage
+ private let authentication: MobileAuthentication?
+ private var state: MobileAuthenticationState?
+ private var preferenceTask: Task?
+ private var loadTask: Task?
+ private var loadGeneration = 0
+
+ init(page: MobilePage, authentication: MobileAuthentication?) {
+ self.page = page
+ self.authentication = authentication
+ super.init(style: .insetGrouped)
+ title = page.title
+ navigationItem.largeTitleDisplayMode = .always
+ navigationItem.rightBarButtonItem = UIBarButtonItem(
+ title: "Update Token",
+ style: .plain,
+ target: self,
+ action: #selector(tokenUpdateRequested)
+ )
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(authenticationDidChange),
+ name: .ironStorageAuthenticationDidChange,
+ object: nil
+ )
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) is not supported")
+ }
+
+ deinit {
+ preferenceTask?.cancel()
+ loadTask?.cancel()
+ NotificationCenter.default.removeObserver(self)
+ }
+
+ override func viewWillAppear(_ animated: Bool) {
+ super.viewWillAppear(animated)
+ reloadShell()
+ }
+
+ override func numberOfSections(in tableView: UITableView) -> Int {
+ page.state == .ready ? 2 : 0
+ }
+
+ override func tableView(
+ _ tableView: UITableView,
+ numberOfRowsInSection section: Int
+ ) -> Int {
+ section == 0 ? 1 : 2
+ }
+
+ override func tableView(
+ _ tableView: UITableView,
+ titleForHeaderInSection section: Int
+ ) -> String? {
+ section == 0 ? "Secure Unlock" : "Authentication Session"
+ }
+
+ override func tableView(
+ _ tableView: UITableView,
+ titleForFooterInSection section: Int
+ ) -> String? {
+ if section == 0 {
+ return "When enabled, the GPG passphrase is device-only, requires a device passcode, and is invalidated when enrolled biometrics change."
+ }
+ return "Manual lock and inactivity expiry immediately revoke the shared Rust authentication lease."
+ }
+
+ override func tableView(
+ _ tableView: UITableView,
+ cellForRowAt indexPath: IndexPath
+ ) -> UITableViewCell {
+ let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
+ var content = cell.defaultContentConfiguration()
+ if indexPath.section == 0 {
+ content.image = UIImage(systemName: "faceid")
+ content.text = "Biometric Unlock"
+ content.secondaryText = state?.biometricUnlockEnabled == true
+ ? "Protected passphrase enrolled"
+ : "Manual passphrase required"
+ let toggle = UISwitch()
+ toggle.isOn = state?.biometricUnlockEnabled == true
+ toggle.isEnabled = authentication != nil
+ toggle.addTarget(self, action: #selector(biometricToggleChanged(_:)), for: .valueChanged)
+ toggle.accessibilityLabel = "Biometric Unlock"
+ cell.accessoryView = toggle
+ cell.selectionStyle = .none
+ } else if indexPath.row == 0 {
+ let unlocked = state?.unlocked == true
+ content.image = UIImage(systemName: unlocked ? "lock.open.fill" : "lock.fill")
+ content.text = unlocked ? "Unlocked" : "Locked"
+ content.secondaryText = unlocked
+ ? "Locks in \(state?.remainingSeconds ?? 0) seconds without activity"
+ : "Protected content is masked"
+ cell.selectionStyle = .none
+ } else {
+ content.image = UIImage(systemName: "lock.fill")
+ content.text = "Lock Now"
+ content.textProperties.color = .systemRed
+ content.secondaryText = "Revoke all active authentication handles"
+ cell.accessoryType = .disclosureIndicator
+ cell.isUserInteractionEnabled = state?.unlocked == true
+ cell.contentView.alpha = state?.unlocked == true ? 1 : 0.45
+ }
+ content.secondaryTextProperties.numberOfLines = 0
+ cell.contentConfiguration = content
+ return cell
+ }
+
+ override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
+ tableView.deselectRow(at: indexPath, animated: true)
+ guard indexPath.section == 1, indexPath.row == 1, let authentication else { return }
+ do {
+ try authentication.manualLock()
+ refreshState()
+ NotificationCenter.default.post(
+ name: .ironStorageAuthenticationDidChange,
+ object: authentication
+ )
+ UIAccessibility.post(notification: .announcement, argument: "IronStorage locked")
+ } catch let error as MobileAuthenticationFfiError {
+ presentAuthenticationFailure(AuthenticationFailure(error))
+ } catch {
+ presentAuthenticationFailure(.unexpected)
+ }
+ }
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ apply(page)
+ }
+
+ private func reloadShell() {
+ loadGeneration += 1
+ let generation = loadGeneration
+ loadTask?.cancel()
+ loadTask = Task { [weak self] in
+ let shell = await Task.detached(priority: .userInitiated) { mobileShell() }.value
+ guard
+ !Task.isCancelled,
+ let self,
+ generation == loadGeneration,
+ let page = shell.pages.first(where: { $0.tab == .preferences })
+ else { return }
+ apply(page)
+ }
+ }
+
+ private func apply(_ page: MobilePage) {
+ self.page = page
+ title = page.title
+ guard page.state == .ready else {
+ var configuration = UIContentUnavailableConfiguration.empty()
+ configuration.image = UIImage(systemName: "gearshape")
+ configuration.text = page.stateTitle
+ configuration.secondaryText = page.stateDetail
+ contentUnavailableConfiguration = configuration
+ tableView.reloadData()
+ return
+ }
+ contentUnavailableConfiguration = nil
+ refreshState()
+ }
+
+ @objc private func tokenUpdateRequested() {
+ navigationController?.pushViewController(TokenUpdateViewController(), animated: true)
+ }
+
+ @objc private func authenticationDidChange() {
+ refreshState()
+ }
+
+ @objc private func biometricToggleChanged(_ sender: UISwitch) {
+ guard let authentication else {
+ sender.setOn(false, animated: true)
+ presentAuthenticationFailure(.unavailable)
+ return
+ }
+ sender.isEnabled = false
+ let enabled = sender.isOn
+ preferenceTask?.cancel()
+ preferenceTask = Task { [weak self, weak sender] in
+ let result = await Task.detached(priority: .userInitiated) {
+ do {
+ return Result.success(
+ try authentication.setBiometricUnlock(enabled: enabled)
+ )
+ } catch let error as MobileAuthenticationFfiError {
+ return .failure(AuthenticationFailure(error))
+ } catch {
+ return .failure(.unexpected)
+ }
+ }.value
+ guard !Task.isCancelled, let self else { return }
+ sender?.isEnabled = true
+ switch result {
+ case let .success(state):
+ self.state = state
+ tableView.reloadData()
+ NotificationCenter.default.post(
+ name: .ironStorageAuthenticationDidChange,
+ object: authentication
+ )
+ case let .failure(failure):
+ sender?.setOn(!enabled, animated: true)
+ presentAuthenticationFailure(failure)
+ }
+ }
+ }
+
+ private func refreshState() {
+ state = try? authentication?.state()
+ tableView.reloadData()
+ }
+}
+
@MainActor
private final class PasswordDirectoryViewController: UITableViewController, MobileTabRoot {
fileprivate let shellTab = MobileTab.passwords
private let path: String?
+ private let authentication: MobileAuthentication?
private var shellPage: MobilePage?
private var directoryPage: MobilePasswordPage?
private var loadTask: Task?
private var loadGeneration = 0
- init(shellPage: MobilePage, path: String? = nil) {
+ init(
+ shellPage: MobilePage,
+ authentication: MobileAuthentication?,
+ path: String? = nil
+ ) {
self.shellPage = shellPage
+ self.authentication = authentication
self.path = path
super.init(style: .insetGrouped)
title = shellPage.title
@@ -639,8 +906,9 @@ private final class PasswordDirectoryViewController: UITableViewController, Mobi
)
}
- private init(path: String, title: String) {
+ private init(path: String, title: String, authentication: MobileAuthentication?) {
self.path = path
+ self.authentication = authentication
shellPage = nil
super.init(style: .insetGrouped)
self.title = title
@@ -719,9 +987,13 @@ private final class PasswordDirectoryViewController: UITableViewController, Mobi
guard let row = directoryPage?.rows[indexPath.row] else { return }
let controller: UIViewController = switch row.kind {
case .directory:
- PasswordDirectoryViewController(path: row.path, title: row.title)
+ PasswordDirectoryViewController(
+ path: row.path,
+ title: row.title,
+ authentication: authentication
+ )
case .entry:
- LockedPasswordViewController(entry: row)
+ LockedPasswordViewController(entry: row, authentication: authentication)
}
navigationController?.pushViewController(controller, animated: true)
}
@@ -872,9 +1144,17 @@ private struct PasswordFailure: Error, Sendable {
@MainActor
private final class LockedPasswordViewController: UIViewController {
private let entry: MobilePasswordRow
+ private let authentication: MobileAuthentication?
+ private let imageView = UIImageView()
+ private let titleLabel = UILabel()
+ private let detailLabel = UILabel()
+ private let unlockButton = UIButton(type: .system)
+ private var unlockTask: Task?
+ private var state: MobileAuthenticationState?
- init(entry: MobilePasswordRow) {
+ init(entry: MobilePasswordRow, authentication: MobileAuthentication?) {
self.entry = entry
+ self.authentication = authentication
super.init(nibName: nil, bundle: nil)
title = entry.title
navigationItem.largeTitleDisplayMode = .never
@@ -888,12 +1168,230 @@ private final class LockedPasswordViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemGroupedBackground
- var configuration = UIContentUnavailableConfiguration.empty()
- configuration.image = UIImage(systemName: "lock.fill")
- configuration.text = "Locked Password"
- configuration.secondaryText = "Authenticate to reveal this password entry."
- contentUnavailableConfiguration = configuration
+ imageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 44)
+ imageView.tintColor = .secondaryLabel
+ titleLabel.font = .preferredFont(forTextStyle: .title2)
+ titleLabel.adjustsFontForContentSizeCategory = true
+ titleLabel.textAlignment = .center
+ detailLabel.font = .preferredFont(forTextStyle: .body)
+ detailLabel.adjustsFontForContentSizeCategory = true
+ detailLabel.textAlignment = .center
+ detailLabel.textColor = .secondaryLabel
+ detailLabel.numberOfLines = 0
+ unlockButton.configuration = .filled()
+ unlockButton.configuration?.cornerStyle = .capsule
+ unlockButton.addTarget(self, action: #selector(unlockRequested), for: .touchUpInside)
+ let stack = UIStackView(arrangedSubviews: [imageView, titleLabel, detailLabel, unlockButton])
+ stack.axis = .vertical
+ stack.alignment = .center
+ stack.spacing = 12
+ stack.setCustomSpacing(24, after: detailLabel)
+ stack.translatesAutoresizingMaskIntoConstraints = false
+ view.addSubview(stack)
+ NSLayoutConstraint.activate([
+ stack.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor),
+ stack.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor),
+ stack.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor),
+ detailLabel.widthAnchor.constraint(lessThanOrEqualToConstant: 360),
+ unlockButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 140),
+ ])
view.accessibilityIdentifier = entry.id
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(authenticationDidChange),
+ name: .ironStorageAuthenticationDidChange,
+ object: nil
+ )
+ render()
+ }
+
+ deinit {
+ unlockTask?.cancel()
+ NotificationCenter.default.removeObserver(self)
+ }
+
+ override func viewWillAppear(_ animated: Bool) {
+ super.viewWillAppear(animated)
+ refreshState()
+ }
+
+ @objc private func authenticationDidChange() {
+ refreshState()
+ }
+
+ @objc private func unlockRequested() {
+ if state?.unlocked == true, let authentication {
+ do {
+ try authentication.touchUserActivity()
+ refreshState()
+ UIAccessibility.post(notification: .announcement, argument: "Unlock extended")
+ } catch let error as MobileAuthenticationFfiError {
+ presentAuthenticationFailure(AuthenticationFailure(error))
+ } catch {
+ presentAuthenticationFailure(.unexpected)
+ }
+ return
+ }
+ unlock(passphrase: nil)
+ }
+
+ @objc private func lockRequested() {
+ guard let authentication else { return }
+ do {
+ try authentication.manualLock()
+ state = try authentication.state()
+ NotificationCenter.default.post(
+ name: .ironStorageAuthenticationDidChange,
+ object: authentication
+ )
+ render()
+ } catch let error as MobileAuthenticationFfiError {
+ presentAuthenticationFailure(AuthenticationFailure(error))
+ } catch {
+ presentAuthenticationFailure(.unexpected)
+ }
+ }
+
+ private func refreshState() {
+ state = try? authentication?.state()
+ render()
+ }
+
+ private func unlock(passphrase: String?) {
+ guard let authentication else {
+ presentAuthenticationFailure(.unavailable)
+ return
+ }
+ unlockTask?.cancel()
+ unlockButton.isEnabled = false
+ unlockButton.configuration?.showsActivityIndicator = true
+ let path = entry.path
+ unlockTask = Task { [weak self] in
+ let result = await Task.detached(priority: .userInitiated) {
+ do {
+ return Result.success(
+ try authentication.unlockEntry(path: path, passphrase: passphrase)
+ )
+ } catch let error as MobileAuthenticationFfiError {
+ return .failure(AuthenticationFailure(error))
+ } catch {
+ return .failure(.unexpected)
+ }
+ }.value
+ guard !Task.isCancelled, let self else { return }
+ unlockButton.isEnabled = true
+ unlockButton.configuration?.showsActivityIndicator = false
+ switch result {
+ case let .success(state):
+ self.state = state
+ NotificationCenter.default.post(
+ name: .ironStorageAuthenticationDidChange,
+ object: authentication
+ )
+ render()
+ UIAccessibility.post(notification: .announcement, argument: "Password unlocked")
+ case let .failure(failure):
+ if passphrase == nil,
+ failure.kind == .passphraseRequired || failure.kind == .biometryUnavailable {
+ promptForPassphrase(message: failure.detail)
+ } else if failure.kind != .cancelled {
+ presentAuthenticationFailure(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))
+ alert.addAction(UIAlertAction(title: "Unlock", style: .default) { [weak self, weak alert] _ in
+ guard let field = alert?.textFields?.first, let value = field.text, !value.isEmpty else {
+ return
+ }
+ field.text = nil
+ self?.unlock(passphrase: value)
+ })
+ present(alert, animated: true)
+ }
+
+ private func render() {
+ let unlocked = state?.unlocked == true
+ imageView.image = UIImage(systemName: unlocked ? "lock.open.fill" : "lock.fill")
+ titleLabel.text = unlocked ? "Key Unlocked" : "Locked Password"
+ detailLabel.text = if unlocked {
+ "The GPG key is available for protected operations for up to \(state?.remainingSeconds ?? 0) seconds."
+ } else {
+ "Authenticate to unlock this password entry. Browsing remains available while locked."
+ }
+ unlockButton.configuration?.title = unlocked ? "Extend Unlock" : "Unlock"
+ navigationItem.rightBarButtonItem = unlocked
+ ? UIBarButtonItem(
+ image: UIImage(systemName: "lock.fill"),
+ style: .plain,
+ target: self,
+ action: #selector(lockRequested)
+ )
+ : nil
+ navigationItem.rightBarButtonItem?.accessibilityLabel = "Lock IronStorage"
+ unlockButton.accessibilityHint = unlocked
+ ? "Extends access after authentication."
+ : "Requests biometric authentication or the GPG key passphrase."
+ }
+}
+
+private struct AuthenticationFailure: Error, Sendable {
+ let kind: MobileAuthenticationErrorKind
+ let title: String
+ let detail: String
+
+ init(_ error: MobileAuthenticationFfiError) {
+ switch error {
+ case let .Failed(kind, title, detail):
+ self.kind = kind
+ self.title = title
+ self.detail = detail
+ }
+ }
+
+ static let unexpected = AuthenticationFailure(
+ kind: .secureStorage,
+ title: "Unlock Failed",
+ detail: "IronStorage could not complete authentication."
+ )
+
+ static let unavailable = AuthenticationFailure(
+ kind: .configuration,
+ title: "Authentication Is Unavailable",
+ detail: "Finish password-store setup before unlocking entries."
+ )
+
+ private init(kind: MobileAuthenticationErrorKind, title: String, detail: String) {
+ self.kind = kind
+ self.title = title
+ self.detail = detail
+ }
+}
+
+@MainActor
+private extension UIViewController {
+ func presentAuthenticationFailure(_ failure: AuthenticationFailure) {
+ let alert = UIAlertController(
+ title: failure.title,
+ message: failure.detail,
+ preferredStyle: .alert
+ )
+ alert.addAction(UIAlertAction(title: "OK", style: .default))
+ present(alert, animated: true)
}
}
diff --git a/apple/project.yml b/apple/project.yml
index ab7b6a1..539523d 100644
--- a/apple/project.yml
+++ b/apple/project.yml
@@ -22,6 +22,7 @@ targets:
properties:
CFBundleDisplayName: IronStorage
ITSAppUsesNonExemptEncryption: false
+ NSFaceIDUsageDescription: Unlock your GPG key for protected password operations.
UILaunchScreen: {}
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
diff --git a/crates/apple/src/lib.rs b/crates/apple/src/lib.rs
index 67aa053..9565d62 100644
--- a/crates/apple/src/lib.rs
+++ b/crates/apple/src/lib.rs
@@ -8,6 +8,11 @@ use std::{error::Error, fmt, sync::Arc};
use ironstorage::{
config::ConfigError,
mobile::{self, MobileShellState as StorageShellState, MobileTab as StorageTab},
+ mobile_authentication::{
+ MobileAuthenticationError as StorageAuthenticationError,
+ MobileAuthenticationErrorKind as StorageAuthenticationErrorKind,
+ MobileAuthenticationState as StorageAuthenticationState,
+ },
mobile_home::{
self, MobileHomeChangeKind as StorageHomeChangeKind,
MobileHomeChangeStatus as StorageHomeChangeStatus, MobileHomeError as StorageHomeError,
@@ -491,6 +496,135 @@ impl From for MobilePasswordFfiError {
}
}
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum MobileAuthenticationErrorKind {
+ PassphraseRequired,
+ InvalidPassphrase,
+ Cancelled,
+ BiometryUnavailable,
+ Configuration,
+ KeyMaterial,
+ Entry,
+ SecureStorage,
+ Expired,
+}
+
+impl From for MobileAuthenticationErrorKind {
+ fn from(kind: StorageAuthenticationErrorKind) -> Self {
+ match kind {
+ StorageAuthenticationErrorKind::PassphraseRequired => Self::PassphraseRequired,
+ StorageAuthenticationErrorKind::InvalidPassphrase => Self::InvalidPassphrase,
+ StorageAuthenticationErrorKind::Cancelled => Self::Cancelled,
+ StorageAuthenticationErrorKind::BiometryUnavailable => Self::BiometryUnavailable,
+ StorageAuthenticationErrorKind::Configuration => Self::Configuration,
+ StorageAuthenticationErrorKind::KeyMaterial => Self::KeyMaterial,
+ StorageAuthenticationErrorKind::Entry => Self::Entry,
+ StorageAuthenticationErrorKind::SecureStorage => Self::SecureStorage,
+ StorageAuthenticationErrorKind::Expired => Self::Expired,
+ }
+ }
+}
+
+#[derive(Clone, uniffi::Record)]
+pub struct MobileAuthenticationState {
+ pub unlocked: bool,
+ pub biometric_unlock_enabled: bool,
+ pub remaining_seconds: u64,
+}
+
+impl From for MobileAuthenticationState {
+ fn from(state: StorageAuthenticationState) -> Self {
+ Self {
+ unlocked: state.unlocked(),
+ biometric_unlock_enabled: state.biometric_unlock_enabled(),
+ remaining_seconds: state.remaining_seconds(),
+ }
+ }
+}
+
+#[derive(Debug, uniffi::Error)]
+pub enum MobileAuthenticationFfiError {
+ Failed {
+ kind: MobileAuthenticationErrorKind,
+ title: String,
+ detail: String,
+ },
+}
+
+impl fmt::Display for MobileAuthenticationFfiError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Failed { title, detail, .. } => write!(formatter, "{title}: {detail}"),
+ }
+ }
+}
+
+impl Error for MobileAuthenticationFfiError {}
+
+impl From for MobileAuthenticationFfiError {
+ fn from(error: StorageAuthenticationError) -> Self {
+ Self::Failed {
+ kind: error.kind().into(),
+ title: error.title().to_owned(),
+ detail: error.detail().to_owned(),
+ }
+ }
+}
+
+#[derive(uniffi::Object)]
+pub struct MobileAuthentication {
+ authentication: ironstorage::mobile_authentication::MobileAuthentication,
+}
+
+#[uniffi::export]
+impl MobileAuthentication {
+ pub fn state(&self) -> Result {
+ self.authentication
+ .state()
+ .map(Into::into)
+ .map_err(Into::into)
+ }
+
+ pub fn unlock_entry(
+ &self,
+ path: String,
+ passphrase: Option,
+ ) -> Result {
+ self.authentication
+ .unlock_entry(
+ &path,
+ passphrase
+ .map(|value| ironstorage::repository::SecretBytes::new(value.into_bytes())),
+ )
+ .map(Into::into)
+ .map_err(Into::into)
+ }
+
+ pub fn set_biometric_unlock(
+ &self,
+ enabled: bool,
+ ) -> Result {
+ self.authentication
+ .set_biometric_unlock(enabled)
+ .map(Into::into)
+ .map_err(Into::into)
+ }
+
+ pub fn touch_user_activity(&self) -> Result<(), MobileAuthenticationFfiError> {
+ self.authentication
+ .touch_user_activity()
+ .map_err(Into::into)
+ }
+
+ pub fn manual_lock(&self) -> Result<(), MobileAuthenticationFfiError> {
+ self.authentication.manual_lock().map_err(Into::into)
+ }
+
+ pub fn cancel(&self) -> Result<(), MobileAuthenticationFfiError> {
+ self.authentication.cancel().map_err(Into::into)
+ }
+}
+
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
pub enum MobileOnboardingPhase {
Validating,
@@ -711,6 +845,13 @@ pub fn mobile_password_page(
.map_err(Into::into)
}
+#[uniffi::export]
+pub fn mobile_authentication() -> Result, MobileAuthenticationFfiError> {
+ Ok(Arc::new(MobileAuthentication {
+ authentication: ironstorage::mobile_authentication::MobileAuthentication::load()?,
+ }))
+}
+
#[uniffi::export]
pub fn mobile_onboarding_operation(
server_url: String,
diff --git a/crates/storage/src/authentication.rs b/crates/storage/src/authentication.rs
index 2ec8955..11bd755 100644
--- a/crates/storage/src/authentication.rs
+++ b/crates/storage/src/authentication.rs
@@ -227,36 +227,7 @@ impl AuthenticationSession
}
let _operation = self.shared.operation()?;
self.shared.expire_if_needed()?;
- let now = self.shared.clock.now();
- let deadline = now
- .checked_add(self.shared.timeout.duration())
- .ok_or(AuthenticationError::ClockOverflow)?;
-
- let (generation, was_active) = {
- let mut state = self.shared.state()?;
- match state.active.as_mut() {
- Some(active) => {
- active.deadline = deadline;
- (active.generation, true)
- }
- None => {
- state.next_generation = state.next_generation.wrapping_add(1);
- let generation = state.next_generation;
- state.active = Some(ActiveLease {
- generation,
- deadline,
- unlock_material: BTreeMap::new(),
- });
- (generation, false)
- }
- }
- };
-
- if !was_active && let Err(error) = self.shared.store.unlock() {
- self.shared
- .revoke_local(generation, RevocationReason::AuthenticationFailed)?;
- return Err(error.into());
- }
+ let generation = self.shared.begin_lease()?;
if key.requires_passphrase()
&& let Err(error) = self.shared.cache_key_passphrase(generation, key)
@@ -273,6 +244,40 @@ impl AuthenticationSession
})
}
+ /// Establish a lease from a manually entered passphrase without persisting it.
+ pub fn authenticate_with_passphrase(
+ &self,
+ key: &KeyInfo,
+ passphrase: SecretBytes,
+ ) -> Result, AuthenticationError> {
+ if !key.has_secret() || !key.requires_passphrase() || passphrase.expose().is_empty() {
+ return Err(AuthenticationError::Locked);
+ }
+ let _operation = self.shared.operation()?;
+ self.shared.expire_if_needed()?;
+ let generation = self.shared.begin_lease()?;
+ let fingerprint = key.fingerprint().as_str().to_owned();
+ self.shared.with_active(generation, |active| {
+ active.unlock_material.insert(fingerprint, passphrase);
+ })?;
+ Ok(AuthenticationHandle {
+ shared: Arc::clone(&self.shared),
+ generation,
+ })
+ }
+
+ /// Remove this key's OS-protected passphrase. A missing item is already disabled.
+ pub fn delete_key_passphrase(&self, key: &KeyInfo) -> Result<(), AuthenticationError> {
+ let _operation = self.shared.operation()?;
+ self.shared.expire_if_needed()?;
+ self.shared.store.unlock()?;
+ let reference = SecretReference::openpgp_passphrase(key.fingerprint().as_str())?;
+ match self.shared.store.delete_openpgp_passphrase(&reference) {
+ Ok(()) | Err(SecretStoreError::Missing) => Ok(()),
+ Err(error) => Err(error.into()),
+ }
+ }
+
/// Remaining active time. Calling this from a timer never extends the lease.
pub fn remaining_time(&self) -> Result