From 873db91204d38ab0a3c6991d528f1b5c5afbced3 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Tue, 11 Aug 2026 18:32:53 +0200 Subject: [PATCH] Implement biometric-protected GPG unlock --- apple/Generated/ironstorage_apple.swift | 468 ++++++++++++++++ apple/Generated/ironstorage_appleFFI.h | 88 +++ apple/Info.plist | 2 + apple/Sources/App/IronStorageApp.swift | 524 +++++++++++++++++- apple/project.yml | 1 + crates/apple/src/lib.rs | 141 +++++ crates/storage/src/authentication.rs | 115 +++- crates/storage/src/config.rs | 39 +- crates/storage/src/crypto.rs | 47 +- crates/storage/src/lib.rs | 1 + crates/storage/src/mobile_authentication.rs | 433 +++++++++++++++ crates/storage/src/secret_store.rs | 60 ++ crates/storage/src/secret_store/platform.rs | 69 ++- crates/storage/tests/authentication_leases.rs | 89 ++- crates/storage/tests/config_contract.rs | 25 + 15 files changed, 2043 insertions(+), 59 deletions(-) create mode 100644 crates/storage/src/mobile_authentication.rs 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, AuthenticationError> { let _operation = self.shared.operation()?; @@ -360,6 +365,25 @@ impl AuthenticationHandle { active.deadline.saturating_sub(now) }) } + + /// Persist the passphrase already verified by a successful crypto operation. + pub fn persist_passphrase(&self, key: &KeyInfo) -> Result<(), AuthenticationError> { + let _operation = self.shared.operation()?; + self.shared.expire_if_needed()?; + let fingerprint = key.fingerprint().as_str(); + let value = self.shared.with_active(self.generation, |active| { + active + .unlock_material + .get(fingerprint) + .map(|value| SecretBytes::new(value.expose().to_vec())) + })?; + let value = value.ok_or(AuthenticationError::Locked)?; + let reference = SecretReference::openpgp_passphrase(fingerprint)?; + self.shared + .store + .store_openpgp_passphrase(&reference, value) + .map_err(Into::into) + } } impl SecretProvider for AuthenticationHandle { @@ -408,6 +432,37 @@ impl Shared { .map_err(|_| AuthenticationError::SecretStore(SecretStoreError::Unavailable)) } + fn begin_lease(&self) -> Result { + let now = self.clock.now(); + let deadline = now + .checked_add(self.timeout.duration()) + .ok_or(AuthenticationError::ClockOverflow)?; + let (generation, was_active) = { + let mut state = self.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.store.unlock() { + self.revoke_local(generation, RevocationReason::AuthenticationFailed)?; + return Err(error.into()); + } + Ok(generation) + } + fn expire_if_needed(&self) -> Result { let now = self.clock.now(); let expired = self diff --git a/crates/storage/src/config.rs b/crates/storage/src/config.rs index 5a675aa..e8f1c2c 100644 --- a/crates/storage/src/config.rs +++ b/crates/storage/src/config.rs @@ -35,6 +35,7 @@ pub struct Config { editor: Option, clipboard_timeout: ClipboardTimeout, authentication_timeout: AuthenticationTimeout, + biometric_unlock_enabled: bool, mobile_tab: MobileTab, mobile_home_refreshed_at: Option, git_remotes: Vec, @@ -122,6 +123,10 @@ impl Config { self.authentication_timeout } + pub fn biometric_unlock_enabled(&self) -> bool { + self.biometric_unlock_enabled + } + pub fn mobile_tab(&self) -> MobileTab { self.mobile_tab } @@ -203,6 +208,31 @@ impl Config { validate_config(self.source.clone(), document, raw)?.persist() } + pub fn update_biometric_unlock(&self, enabled: bool) -> Result<(), ConfigError> { + let mut document = self.document.clone(); + let root = document + .as_table_mut() + .ok_or_else(|| ConfigError::Malformed { + path: self.source.clone(), + })?; + let security = root + .entry("security") + .or_insert_with(|| toml::Value::Table(toml::Table::new())) + .as_table_mut() + .ok_or(ConfigError::InvalidField { field: "security" })?; + security.insert( + "biometric_unlock_enabled".to_owned(), + toml::Value::Boolean(enabled), + ); + 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 create_mobile_clone( source: PathBuf, vault: &Path, @@ -835,6 +865,7 @@ struct RawConfig { #[serde(deny_unknown_fields)] struct RawSecurity { inactivity_timeout_seconds: Option, + biometric_unlock_enabled: Option, } #[derive(Default, Deserialize)] @@ -916,6 +947,7 @@ fn validate_config( .map_err(|_| ConfigError::InvalidField { field: "security.inactivity_timeout_seconds", })?; + let biometric_unlock_enabled = raw.security.biometric_unlock_enabled.unwrap_or(false); let mobile_tab = raw .ui .selected_mobile_tab @@ -943,6 +975,7 @@ fn validate_config( editor, clipboard_timeout, authentication_timeout, + biometric_unlock_enabled, mobile_tab, mobile_home_refreshed_at, git_remotes, @@ -1113,7 +1146,11 @@ fn validate_known_fields(value: &toml::Value, source: &Path) -> Result<(), Confi let security = security.as_table().ok_or_else(|| ConfigError::Malformed { path: source.to_owned(), })?; - validate_table(security, "security", &["inactivity_timeout_seconds"])?; + validate_table( + security, + "security", + &["inactivity_timeout_seconds", "biometric_unlock_enabled"], + )?; } if let Some(ui) = root.get("ui") { let ui = ui.as_table().ok_or_else(|| ConfigError::Malformed { diff --git a/crates/storage/src/crypto.rs b/crates/storage/src/crypto.rs index a381647..06e817d 100644 --- a/crates/storage/src/crypto.rs +++ b/crates/storage/src/crypto.rs @@ -407,6 +407,27 @@ impl KeyStore { Ok(actual == expected) } + /// Return the secret keys named by a pass entry's PKESK packets. + pub fn decrypting_keys( + &self, + ciphertext: &EncryptedEntry, + ) -> Result, CryptoError> { + let message = Message::from_bytes(Cursor::new(ciphertext.as_bytes())) + .map_err(|_| CryptoError::CorruptMessage)?; + if !message.is_encrypted() { + return Err(CryptoError::CorruptMessage); + } + let keys = self + .decrypting_material(&message) + .into_iter() + .map(|(_, material)| key_info(material)) + .collect::>(); + if keys.is_empty() { + return Err(CryptoError::MissingSecretKey); + } + Ok(keys) + } + /// Decrypt a pass entry with only the secret keys named by its PKESK packets. pub fn decrypt( &self, @@ -418,16 +439,7 @@ impl KeyStore { if !message.is_encrypted() { return Err(CryptoError::CorruptMessage); } - let candidates = self - .keys - .iter() - .filter(|(_, material)| { - material - .secret - .as_ref() - .is_some_and(|secret| message_matches_secret(&message, secret)) - }) - .collect::>(); + let candidates = self.decrypting_material(&message); if candidates.is_empty() { return Err(CryptoError::MissingSecretKey); } @@ -491,6 +503,21 @@ impl KeyStore { } } + fn decrypting_material<'a>( + &'a self, + message: &Message<'_>, + ) -> Vec<(&'a KeyFingerprint, &'a KeyMaterial)> { + self.keys + .iter() + .filter(|(_, material)| { + material + .secret + .as_ref() + .is_some_and(|secret| message_matches_secret(message, secret)) + }) + .collect() + } + pub fn sign( &self, data: &[u8], diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index a668ea6..e9ce4b5 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -15,6 +15,7 @@ pub mod generate; pub mod git; pub mod kdbx; pub mod mobile; +pub mod mobile_authentication; pub mod mobile_home; pub mod mobile_onboarding; pub mod mobile_passwords; diff --git a/crates/storage/src/mobile_authentication.rs b/crates/storage/src/mobile_authentication.rs new file mode 100644 index 0000000..1e64a0d --- /dev/null +++ b/crates/storage/src/mobile_authentication.rs @@ -0,0 +1,433 @@ +//! Shared mobile authentication state; Swift only supplies input and presents results. + +use std::{error::Error, fmt, sync::Mutex}; + +use crate::{ + authentication::{ + AuthenticationError, NativeAuthenticationHandle, NativeAuthenticationSession, + }, + config::{Config, ConfigError}, + crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider, SecretProviderError}, + repository::{EntryPath, Repository, RepositoryError, SecretBytes}, + secret_store::{SecretProtectionPolicy, SecretStoreError}, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MobileAuthenticationErrorKind { + PassphraseRequired, + InvalidPassphrase, + Cancelled, + BiometryUnavailable, + Configuration, + KeyMaterial, + Entry, + SecureStorage, + Expired, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MobileAuthenticationError { + kind: MobileAuthenticationErrorKind, + title: String, + detail: String, +} + +impl MobileAuthenticationError { + pub fn kind(&self) -> MobileAuthenticationErrorKind { + self.kind + } + + pub fn title(&self) -> &str { + &self.title + } + + pub fn detail(&self) -> &str { + &self.detail + } + + fn new(kind: MobileAuthenticationErrorKind, title: &str, detail: impl Into) -> Self { + Self { + kind, + title: title.to_owned(), + detail: detail.into(), + } + } + + fn passphrase_required(detail: impl Into) -> Self { + Self::new( + MobileAuthenticationErrorKind::PassphraseRequired, + "Passphrase Required", + detail, + ) + } + + fn authentication(error: AuthenticationError) -> Self { + match error { + AuthenticationError::Cancelled => Self::new( + MobileAuthenticationErrorKind::Cancelled, + "Authentication Cancelled", + "No protected content was unlocked.", + ), + AuthenticationError::Expired => Self::new( + MobileAuthenticationErrorKind::Expired, + "IronStorage Locked", + "The authentication lease expired.", + ), + AuthenticationError::SecretStore(SecretStoreError::Missing) => { + Self::passphrase_required("Enter the GPG key passphrase to continue.") + } + AuthenticationError::SecretStore( + SecretStoreError::Denied + | SecretStoreError::UnsupportedProtection + | SecretStoreError::Unavailable, + ) => Self::new( + MobileAuthenticationErrorKind::BiometryUnavailable, + "Biometric Unlock Unavailable", + "Enter the GPG key passphrase to recover or continue without biometric unlock.", + ), + error => Self::new( + MobileAuthenticationErrorKind::SecureStorage, + "Secure Unlock Failed", + error.to_string(), + ), + } + } +} + +impl fmt::Display for MobileAuthenticationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}: {}", self.title, self.detail) + } +} + +impl Error for MobileAuthenticationError {} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct MobileAuthenticationState { + unlocked: bool, + biometric_unlock_enabled: bool, + remaining_seconds: u64, +} + +impl MobileAuthenticationState { + pub fn unlocked(self) -> bool { + self.unlocked + } + + pub fn biometric_unlock_enabled(self) -> bool { + self.biometric_unlock_enabled + } + + pub fn remaining_seconds(self) -> u64 { + self.remaining_seconds + } +} + +struct ActiveMobileLease { + handle: NativeAuthenticationHandle, + key: KeyInfo, +} + +struct MobileAuthenticationStatus { + biometric_unlock_enabled: bool, + active: Option, +} + +/// One process-wide mobile authentication lease shared by every tab and viewer. +pub struct MobileAuthentication { + config: Config, + repository: Repository, + keys: KeyStore, + session: NativeAuthenticationSession, + status: Mutex, +} + +impl MobileAuthentication { + pub fn load() -> Result { + let config = Config::load(None).map_err(|error| { + MobileAuthenticationError::new( + MobileAuthenticationErrorKind::Configuration, + "Authentication Is Unavailable", + error.to_string(), + ) + })?; + let repository = Repository::open(config.vault()).map_err(entry_error)?; + let keys = KeyStore::load(config.key_material()).map_err(key_error)?; + let session = NativeAuthenticationSession::system( + SecretProtectionPolicy::current_biometry_for_openpgp(), + config.authentication_timeout(), + ) + .map_err(MobileAuthenticationError::authentication)?; + Ok(Self { + status: Mutex::new(MobileAuthenticationStatus { + biometric_unlock_enabled: config.biometric_unlock_enabled(), + active: None, + }), + config, + repository, + keys, + session, + }) + } + + /// Validate an entry unlock and retain only the key passphrase for the shared lease. + pub fn unlock_entry( + &self, + path: &str, + passphrase: Option, + ) -> Result { + let entry = EntryPath::parse(path).map_err(entry_error)?; + let ciphertext = self.repository.read_entry(&entry).map_err(entry_error)?; + let candidates = self.keys.decrypting_keys(&ciphertext).map_err(key_error)?; + + if let Some(active) = self.take_active()? { + let mut provider = KeyOnlyProvider::new(active.handle.clone(), &active.key); + if self.keys.decrypt(&ciphertext, &mut provider).is_ok() { + self.restore_active(active)?; + return self.state(); + } + self.session + .manual_lock() + .map_err(MobileAuthenticationError::authentication)?; + } + + let biometric_enabled = self.status()?.biometric_unlock_enabled; + match passphrase { + Some(passphrase) => { + for key in &candidates { + let candidate = SecretBytes::new(passphrase.expose().to_vec()); + let handle = self + .session + .authenticate_with_passphrase(key, candidate) + .map_err(MobileAuthenticationError::authentication)?; + let mut provider = KeyOnlyProvider::new(handle.clone(), key); + match self.keys.decrypt(&ciphertext, &mut provider) { + Ok(plaintext) => { + drop(plaintext); + if biometric_enabled { + handle + .persist_passphrase(key) + .map_err(MobileAuthenticationError::authentication)?; + } + self.set_active(handle, key.clone())?; + return self.state(); + } + Err(CryptoError::DecryptionFailed) => { + self.session + .manual_lock() + .map_err(MobileAuthenticationError::authentication)?; + } + Err(error) => return Err(key_error(error)), + } + } + Err(MobileAuthenticationError::new( + MobileAuthenticationErrorKind::InvalidPassphrase, + "Incorrect Passphrase", + "The GPG key could not be unlocked.", + )) + } + None if biometric_enabled => { + for key in &candidates { + let handle = match self.session.authenticate(key) { + Ok(handle) => handle, + Err(AuthenticationError::Cancelled) => { + return Err(MobileAuthenticationError::authentication( + AuthenticationError::Cancelled, + )); + } + Err(_) => continue, + }; + let mut provider = KeyOnlyProvider::new(handle.clone(), key); + if let Ok(plaintext) = self.keys.decrypt(&ciphertext, &mut provider) { + drop(plaintext); + self.set_active(handle, key.clone())?; + return self.state(); + } + let _ = self.session.delete_key_passphrase(key); + let _ = self.session.manual_lock(); + } + Err(MobileAuthenticationError::passphrase_required( + "Biometric unlock could not restore this key. Enter its GPG passphrase to recover.", + )) + } + None => Err(MobileAuthenticationError::passphrase_required( + "Enter the GPG key passphrase to continue.", + )), + } + } + + pub fn set_biometric_unlock( + &self, + enabled: bool, + ) -> Result { + if enabled { + let status = self.status()?; + if status.biometric_unlock_enabled { + drop(status); + return self.state(); + } + let active = status.active.as_ref().ok_or_else(|| { + MobileAuthenticationError::passphrase_required( + "Unlock a password entry before enabling biometric unlock.", + ) + })?; + active + .handle + .persist_passphrase(&active.key) + .map_err(MobileAuthenticationError::authentication)?; + drop(status); + if let Err(error) = self.config.update_biometric_unlock(true) { + let _ = self.delete_all_key_passphrases(); + let _ = self.manual_lock(); + return Err(config_error(error)); + } + self.status()?.biometric_unlock_enabled = true; + } else { + self.delete_all_key_passphrases()?; + self.config + .update_biometric_unlock(false) + .map_err(config_error)?; + self.session + .manual_lock() + .map_err(MobileAuthenticationError::authentication)?; + let mut status = self.status()?; + status.biometric_unlock_enabled = false; + status.active = None; + } + self.state() + } + + pub fn state(&self) -> Result { + let remaining = self + .session + .remaining_time() + .map_err(MobileAuthenticationError::authentication)?; + let mut status = self.status()?; + if remaining.is_none() { + status.active = None; + } + Ok(MobileAuthenticationState { + unlocked: status.active.is_some(), + biometric_unlock_enabled: status.biometric_unlock_enabled, + remaining_seconds: remaining.map_or(0, |duration| duration.as_secs()), + }) + } + + pub fn touch_user_activity(&self) -> Result<(), MobileAuthenticationError> { + let status = self.status()?; + let active = status.active.as_ref().ok_or_else(|| { + MobileAuthenticationError::new( + MobileAuthenticationErrorKind::Expired, + "IronStorage Locked", + "Authenticate before using protected content.", + ) + })?; + active + .handle + .touch_user_activity() + .map_err(MobileAuthenticationError::authentication) + } + + pub fn manual_lock(&self) -> Result<(), MobileAuthenticationError> { + self.session + .manual_lock() + .map_err(MobileAuthenticationError::authentication)?; + self.status()?.active = None; + Ok(()) + } + + pub fn cancel(&self) -> Result<(), MobileAuthenticationError> { + self.session + .cancel() + .map_err(MobileAuthenticationError::authentication)?; + self.status()?.active = None; + Ok(()) + } + + fn delete_all_key_passphrases(&self) -> Result<(), MobileAuthenticationError> { + for key in self.keys.infos().filter(KeyInfo::has_secret) { + self.session + .delete_key_passphrase(&key) + .map_err(MobileAuthenticationError::authentication)?; + } + Ok(()) + } + + fn status( + &self, + ) -> Result, MobileAuthenticationError> + { + self.status.lock().map_err(|_| { + MobileAuthenticationError::new( + MobileAuthenticationErrorKind::SecureStorage, + "Authentication Is Unavailable", + "The shared authentication state is unavailable.", + ) + }) + } + + fn take_active(&self) -> Result, MobileAuthenticationError> { + Ok(self.status()?.active.take()) + } + + fn restore_active(&self, active: ActiveMobileLease) -> Result<(), MobileAuthenticationError> { + self.status()?.active = Some(active); + Ok(()) + } + + fn set_active( + &self, + handle: NativeAuthenticationHandle, + key: KeyInfo, + ) -> Result<(), MobileAuthenticationError> { + self.status()?.active = Some(ActiveMobileLease { handle, key }); + Ok(()) + } +} + +struct KeyOnlyProvider<'a> { + handle: NativeAuthenticationHandle, + fingerprint: &'a str, +} + +impl<'a> KeyOnlyProvider<'a> { + fn new(handle: NativeAuthenticationHandle, key: &'a KeyInfo) -> Self { + Self { + handle, + fingerprint: key.fingerprint().as_str(), + } + } +} + +impl SecretProvider for KeyOnlyProvider<'_> { + fn secret_for(&mut self, key: &KeyInfo) -> Result { + if key.fingerprint().as_str() != self.fingerprint { + return Err(SecretProviderError::Missing); + } + self.handle.secret_for(key) + } +} + +fn config_error(error: ConfigError) -> MobileAuthenticationError { + MobileAuthenticationError::new( + MobileAuthenticationErrorKind::Configuration, + "Preference Was Not Saved", + error.to_string(), + ) +} + +fn entry_error(error: RepositoryError) -> MobileAuthenticationError { + MobileAuthenticationError::new( + MobileAuthenticationErrorKind::Entry, + "Password Entry Is Unavailable", + error.to_string(), + ) +} + +fn key_error(error: CryptoError) -> MobileAuthenticationError { + MobileAuthenticationError::new( + MobileAuthenticationErrorKind::KeyMaterial, + "GPG Key Unlock Failed", + error.to_string(), + ) +} diff --git a/crates/storage/src/secret_store.rs b/crates/storage/src/secret_store.rs index 0f3756d..d1b3b4b 100644 --- a/crates/storage/src/secret_store.rs +++ b/crates/storage/src/secret_store.rs @@ -170,6 +170,7 @@ impl SecretLocator { pub enum SecretProtection { DeviceUnlocked, RequireUserPresence, + BiometryCurrentSet, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -197,6 +198,13 @@ impl SecretProtectionPolicy { ) } + pub const fn current_biometry_for_openpgp() -> Self { + Self::new( + SecretProtection::BiometryCurrentSet, + SecretProtection::DeviceUnlocked, + ) + } + fn for_reference(self, reference: &SecretReference) -> SecretProtection { match &reference.kind { SecretReferenceKind::OpenPgpPassphrase { .. } => self.openpgp, @@ -474,6 +482,40 @@ impl SecretStore { Ok(()) } + /// Persist a verified OpenPGP passphrase without first reading the old item. + /// This lets Apple replace an item invalidated by biometric enrollment changes. + pub(crate) fn store_openpgp_passphrase( + &self, + reference: &SecretReference, + value: SecretBytes, + ) -> Result<(), SecretStoreError> { + if !matches!( + reference.kind, + SecretReferenceKind::OpenPgpPassphrase { .. } + ) { + return Err(SecretStoreError::InvalidReference); + } + validate_secret(&value)?; + let locator = reference.locator(); + let encoded = encode_record(reference, &value)?; + let mut state = self.unlocked_state()?; + match self.backend.replace( + &locator, + self.protections.for_reference(reference), + encoded.expose(), + ) { + Ok(()) => {} + Err(SecretStoreError::Missing) => self.backend.create( + &locator, + self.protections.for_reference(reference), + encoded.expose(), + )?, + Err(error) => return Err(error), + } + self.cache_insert(&mut state, locator, encoded); + Ok(()) + } + /// Create or replace one HTTPS Git account/token record without exposing /// the previously stored account or token to a frontend. pub fn store_https_git_credential( @@ -525,6 +567,24 @@ impl SecretStore { Ok(()) } + pub(crate) fn delete_openpgp_passphrase( + &self, + reference: &SecretReference, + ) -> Result<(), SecretStoreError> { + if !matches!( + reference.kind, + SecretReferenceKind::OpenPgpPassphrase { .. } + ) { + return Err(SecretStoreError::InvalidReference); + } + let locator = reference.locator(); + let mut state = self.unlocked_state()?; + self.backend + .delete(&locator, self.protections.for_reference(reference))?; + state.cache.remove(&locator); + Ok(()) + } + fn retrieve_git_record( &self, server: &ServerId, diff --git a/crates/storage/src/secret_store/platform.rs b/crates/storage/src/secret_store/platform.rs index 209fc48..05c3285 100644 --- a/crates/storage/src/secret_store/platform.rs +++ b/crates/storage/src/secret_store/platform.rs @@ -11,6 +11,15 @@ use std::sync::Arc; #[cfg(any(target_os = "ios", target_os = "macos"))] use std::collections::HashMap; +#[cfg(any(target_os = "ios", target_os = "macos"))] +use security_framework::{ + access_control::{ProtectionMode, SecAccessControl}, + passwords::{ + AccessControlOptions, PasswordOptions, delete_generic_password_options, generic_password, + set_generic_password_options, + }, +}; + use keyring_core::{CredentialStore, Entry}; use super::{SecretLocator, SecretProtection, SecretStoreBackend, SecretStoreError}; @@ -77,7 +86,7 @@ impl NativeSecretBackend { let (service, user) = locator.service_and_user(); #[cfg(any(target_os = "linux", target_os = "windows"))] { - if protection == SecretProtection::RequireUserPresence { + if protection != SecretProtection::DeviceUnlocked { return Err(SecretStoreError::UnsupportedProtection); } self.store.build(service, &user, None).map_err(map_error) @@ -101,6 +110,7 @@ impl NativeSecretBackend { .build(service, &user, modifiers.as_ref()) .map_err(map_error) } + SecretProtection::BiometryCurrentSet => Err(SecretStoreError::Unavailable), } } #[cfg(not(any( @@ -123,6 +133,10 @@ impl SecretStoreBackend for NativeSecretBackend { protection: SecretProtection, value: &[u8], ) -> Result<(), SecretStoreError> { + #[cfg(any(target_os = "ios", target_os = "macos"))] + if protection == SecretProtection::BiometryCurrentSet { + return strict_set(locator, value); + } let entry = self.entry(locator, protection)?; match entry.get_secret() { Ok(existing) => { @@ -139,6 +153,12 @@ impl SecretStoreBackend for NativeSecretBackend { locator: &SecretLocator, protection: SecretProtection, ) -> Result { + #[cfg(any(target_os = "ios", target_os = "macos"))] + if protection == SecretProtection::BiometryCurrentSet { + return generic_password(strict_query(locator)) + .map(SecretBytes::new) + .map_err(map_security_error); + } self.entry(locator, protection)? .get_secret() .map(SecretBytes::new) @@ -151,6 +171,11 @@ impl SecretStoreBackend for NativeSecretBackend { protection: SecretProtection, value: &[u8], ) -> Result<(), SecretStoreError> { + #[cfg(any(target_os = "ios", target_os = "macos"))] + if protection == SecretProtection::BiometryCurrentSet { + delete_generic_password_options(strict_query(locator)).map_err(map_security_error)?; + return strict_set(locator, value); + } let entry = self.entry(locator, protection)?; let existing = entry.get_secret().map_err(map_error)?; drop(SecretBytes::new(existing)); @@ -162,6 +187,11 @@ impl SecretStoreBackend for NativeSecretBackend { locator: &SecretLocator, protection: SecretProtection, ) -> Result<(), SecretStoreError> { + #[cfg(any(target_os = "ios", target_os = "macos"))] + if protection == SecretProtection::BiometryCurrentSet { + return delete_generic_password_options(strict_query(locator)) + .map_err(map_security_error); + } self.entry(locator, protection)? .delete_credential() .map_err(map_error) @@ -175,6 +205,43 @@ fn presence_modifiers(protection: SecretProtection) -> Option { Some(HashMap::from([("access-policy", "require-user-presence")])) } + SecretProtection::BiometryCurrentSet => None, + } +} + +#[cfg(any(target_os = "ios", target_os = "macos"))] +fn strict_query(locator: &SecretLocator) -> PasswordOptions { + let (service, account) = locator.service_and_user(); + let mut options = PasswordOptions::new_generic_password(service, &account); + options.use_protected_keychain(); + options +} + +#[cfg(any(target_os = "ios", target_os = "macos"))] +fn strict_create_options(locator: &SecretLocator) -> Result { + let access = SecAccessControl::create_with_protection( + Some(ProtectionMode::AccessibleWhenPasscodeSetThisDeviceOnly), + AccessControlOptions::BIOMETRY_CURRENT_SET.bits(), + ) + .map_err(map_security_error)?; + let mut options = strict_query(locator); + options.set_access_control(access); + Ok(options) +} + +#[cfg(any(target_os = "ios", target_os = "macos"))] +fn strict_set(locator: &SecretLocator, value: &[u8]) -> Result<(), SecretStoreError> { + set_generic_password_options(value, strict_create_options(locator)?).map_err(map_security_error) +} + +#[cfg(any(target_os = "ios", target_os = "macos"))] +fn map_security_error(error: security_framework::base::Error) -> SecretStoreError { + match error.code() { + -25300 => SecretStoreError::Missing, + -128 => SecretStoreError::Cancelled, + -25293 | -25308 => SecretStoreError::Denied, + -50 => SecretStoreError::UnsupportedProtection, + _ => SecretStoreError::Unavailable, } } diff --git a/crates/storage/tests/authentication_leases.rs b/crates/storage/tests/authentication_leases.rs index 3f843e5..80236d8 100644 --- a/crates/storage/tests/authentication_leases.rs +++ b/crates/storage/tests/authentication_leases.rs @@ -33,6 +33,7 @@ struct BackendState { retrieves: usize, locks: usize, unlocks: usize, + last_protection: Option, } #[derive(Clone, Default)] @@ -51,6 +52,14 @@ impl MemoryBackend { self.0.lock().expect("test mutex").locks } + fn last_protection(&self) -> Option { + self.0.lock().expect("test mutex").last_protection + } + + fn invalidate_enrollment(&self) { + self.0.lock().expect("test mutex").values.clear(); + } + fn take_fault(state: &mut BackendState) -> Result<(), SecretStoreError> { match state.fault.take() { Some(error) => Err(error), @@ -63,11 +72,12 @@ impl SecretStoreBackend for MemoryBackend { fn create( &self, locator: &SecretLocator, - _protection: SecretProtection, + protection: SecretProtection, value: &[u8], ) -> Result<(), SecretStoreError> { let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?; Self::take_fault(&mut state)?; + state.last_protection = Some(protection); if state.values.contains_key(locator) { return Err(SecretStoreError::AlreadyExists); } @@ -80,10 +90,11 @@ impl SecretStoreBackend for MemoryBackend { fn retrieve( &self, locator: &SecretLocator, - _protection: SecretProtection, + protection: SecretProtection, ) -> Result { let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?; Self::take_fault(&mut state)?; + state.last_protection = Some(protection); state.retrieves += 1; state .values @@ -95,11 +106,12 @@ impl SecretStoreBackend for MemoryBackend { fn replace( &self, locator: &SecretLocator, - _protection: SecretProtection, + protection: SecretProtection, value: &[u8], ) -> Result<(), SecretStoreError> { let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?; Self::take_fault(&mut state)?; + state.last_protection = Some(protection); let existing = state .values .get_mut(locator) @@ -111,10 +123,11 @@ impl SecretStoreBackend for MemoryBackend { fn delete( &self, locator: &SecretLocator, - _protection: SecretProtection, + protection: SecretProtection, ) -> Result<(), SecretStoreError> { let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?; Self::take_fault(&mut state)?; + state.last_protection = Some(protection); state .values .remove(locator) @@ -356,3 +369,71 @@ fn exact_deadline_races_are_serialized_and_timeout_validation_is_bounded() -> Te assert_eq!(backend.locks(), baseline_locks + 1, "expiry relocks once"); Ok(()) } + +#[test] +fn verified_manual_recovery_enrolls_current_biometry_and_reestablishes_the_lease() -> TestResult { + let fixture = FixtureSet::load()?; + let store = fixture.materialize_store("basic")?; + let repository = Repository::open(store.path())?; + let keys = KeyStore::load(fixture.path("keys"))?; + let alice = fixture_key(&fixture, &keys, "alice")?; + let backend = MemoryBackend::default(); + let clock = ManualClock::default(); + let session = AuthenticationSession::with_clock( + backend.clone(), + SecretProtectionPolicy::current_biometry_for_openpgp(), + AuthenticationTimeout::new(Duration::from_secs(30))?, + clock.clone(), + ); + let reader = VaultReader::new(&repository, &keys); + + assert!(matches!( + session.authenticate(&alice), + Err(AuthenticationError::SecretStore(SecretStoreError::Missing)) + )); + let mut manual = session.authenticate_with_passphrase( + &alice, + SecretBytes::new(fixture.key("alice")?.passphrase.as_bytes().to_vec()), + )?; + assert!(matches!( + reader.show(Some("email/personal"), &mut manual)?, + ShowResult::Entry(_) + )); + manual.persist_passphrase(&alice)?; + assert_eq!( + backend.last_protection(), + Some(SecretProtection::BiometryCurrentSet) + ); + + session.manual_lock()?; + let mut biometric = session.authenticate(&alice)?; + assert!(matches!( + reader.show(Some("email/personal"), &mut biometric)?, + ShowResult::Entry(_) + )); + clock.advance(Duration::from_secs(30)); + assert!(session.expire()?); + assert_eq!(biometric.ensure_active(), Err(AuthenticationError::Expired)); + + backend.invalidate_enrollment(); + assert!(matches!( + session.authenticate(&alice), + Err(AuthenticationError::SecretStore(SecretStoreError::Missing)) + )); + let mut recovered = session.authenticate_with_passphrase( + &alice, + SecretBytes::new(fixture.key("alice")?.passphrase.as_bytes().to_vec()), + )?; + assert!(matches!( + reader.show(Some("email/personal"), &mut recovered)?, + ShowResult::Entry(_) + )); + recovered.persist_passphrase(&alice)?; + session.delete_key_passphrase(&alice)?; + session.manual_lock()?; + assert!(matches!( + session.authenticate(&alice), + Err(AuthenticationError::SecretStore(SecretStoreError::Missing)) + )); + Ok(()) +} diff --git a/crates/storage/tests/config_contract.rs b/crates/storage/tests/config_contract.rs index a78f375..91d1a8a 100644 --- a/crates/storage/tests/config_contract.rs +++ b/crates/storage/tests/config_contract.rs @@ -210,6 +210,31 @@ fn mobile_tab_defaults_and_persists_through_storage_configuration() -> TestResul Ok(()) } +#[test] +fn biometric_preference_is_secret_free_and_defaults_to_disabled() -> TestResult { + let fixture = ConfigurationFixture::new()?; + fs::create_dir_all(fixture.temporary.path().join("cwd/vault"))?; + fixture.write_explicit(fixture.valid_contents())?; + let config = fixture.loader().load(Some(&fixture.explicit_path()))?; + assert!(!config.biometric_unlock_enabled()); + + config.update_biometric_unlock(true)?; + let contents = fs::read_to_string(fixture.explicit_path())?; + assert!(contents.contains("biometric_unlock_enabled = true")); + assert!(!contents.to_ascii_lowercase().contains("passphrase")); + let reloaded = fixture.loader().load(Some(&fixture.explicit_path()))?; + assert!(reloaded.biometric_unlock_enabled()); + + reloaded.update_biometric_unlock(false)?; + assert!( + !fixture + .loader() + .load(Some(&fixture.explicit_path()))? + .biometric_unlock_enabled() + ); + Ok(()) +} + #[test] fn desktop_vault_switch_preserves_and_reloads_the_shared_configuration() -> TestResult { let fixture = ConfigurationFixture::new()?;