Implement biometric-protected GPG unlock

This commit is contained in:
2026-08-11 18:32:53 +02:00
parent 3295761bcf
commit 873db91204
15 changed files with 2043 additions and 59 deletions

View File

@@ -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) #if swift(>=5.8)
@_documentation(visibility: private) @_documentation(visibility: private)
#endif #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 { public protocol MobileHomeOperationProtocol: AnyObject, Sendable {
func cached() throws -> MobileHomePage 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 struct MobileHomeChange: Equatable, Hashable {
public var id: String public var id: String
public var title: 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 { public enum MobileHomeChangeKind: Equatable, Hashable {
case passwordEntry case passwordEntry
@@ -3198,6 +3638,13 @@ fileprivate struct FfiConverterSequenceTypeMobilePasswordRow: FfiConverterRustBu
return seq 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 { public func mobileHomeOperation() -> MobileHomeOperation {
return try! FfiConverterTypeMobileHomeOperation_lift(try! rustCall() { return try! FfiConverterTypeMobileHomeOperation_lift(try! rustCall() {
uniffiCallStatus in uniffiCallStatus in
@@ -3277,6 +3724,9 @@ private let initializationResult: InitializationResult = {
if bindings_contract_version != scaffolding_contract_version { if bindings_contract_version != scaffolding_contract_version {
return InitializationResult.contractVersionMismatch 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) { if (uniffi_ironstorage_apple_checksum_func_mobile_home_operation() != 10595) {
return InitializationResult.apiChecksumMismatch return InitializationResult.apiChecksumMismatch
} }
@@ -3301,6 +3751,24 @@ private let initializationResult: InitializationResult = {
if (uniffi_ironstorage_apple_checksum_func_set_selected_mobile_tab() != 65280) { if (uniffi_ironstorage_apple_checksum_func_set_selected_mobile_tab() != 65280) {
return InitializationResult.apiChecksumMismatch 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) { if (uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_cached() != 32436) {
return InitializationResult.apiChecksumMismatch return InitializationResult.apiChecksumMismatch
} }

View File

@@ -242,6 +242,46 @@ typedef struct UniffiForeignFutureResultVoid {
typedef void (*UniffiForeignFutureCompleteVoid)(uint64_t, 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 #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEHOMEOPERATION #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEHOMEOPERATION
#define 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 #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_SETUP
#define 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 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 #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_HOME_OPERATION #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 #ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_APPLE_RUST_FUTURE_COMPLETE_VOID
#define 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 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 #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_HOME_OPERATION #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 #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_SET_SELECTED_MOBILE_TAB
uint16_t uniffi_ironstorage_apple_checksum_func_set_selected_mobile_tab(void 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 #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_CACHED #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_CACHED

View File

@@ -22,6 +22,8 @@
<string>1</string> <string>1</string>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>
<false/> <false/>
<key>NSFaceIDUsageDescription</key>
<string>Unlock your GPG key for protected password operations.</string>
<key>UILaunchScreen</key> <key>UILaunchScreen</key>
<dict/> <dict/>
<key>UISupportedInterfaceOrientations</key> <key>UISupportedInterfaceOrientations</key>

View File

@@ -4,6 +4,9 @@ extension Notification.Name {
static let ironStorageLocalStoreDidChange = Notification.Name( static let ironStorageLocalStoreDidChange = Notification.Name(
"de.rfc1437.ironstorage.local-store-did-change" "de.rfc1437.ironstorage.local-store-did-change"
) )
static let ironStorageAuthenticationDidChange = Notification.Name(
"de.rfc1437.ironstorage.authentication-did-change"
)
} }
@main @main
@@ -33,15 +36,27 @@ private protocol MobileTabRoot: AnyObject {
@MainActor @MainActor
private final class AppContext: NSObject, UITabBarControllerDelegate { private final class AppContext: NSObject, UITabBarControllerDelegate {
private let tabs = UITabBarController() private let tabs = UITabBarController()
private let authentication = try? mobileAuthentication()
private var navigationControllers: [UINavigationController] = [] private var navigationControllers: [UINavigationController] = []
private var restoreTask: Task<Void, Never>? private var restoreTask: Task<Void, Never>?
private var authenticationMonitor: Task<Void, Never>?
deinit {
restoreTask?.cancel()
authenticationMonitor?.cancel()
}
func makeRootController() -> UIViewController { func makeRootController() -> UIViewController {
let shell = mobileShellFixture(state: .loading) let shell = mobileShellFixture(state: .loading)
navigationControllers = shell.pages.map { page in navigationControllers = shell.pages.map { page in
let root: UIViewController = page.tab == .passwords let root: UIViewController = switch page.tab {
? PasswordDirectoryViewController(shellPage: page) case .passwords:
: ShellViewController(page: page) PasswordDirectoryViewController(shellPage: page, authentication: authentication)
case .preferences:
PreferencesViewController(page: page, authentication: authentication)
default:
ShellViewController(page: page)
}
let navigation = UINavigationController(rootViewController: root) let navigation = UINavigationController(rootViewController: root)
navigation.navigationBar.prefersLargeTitles = true navigation.navigationBar.prefersLargeTitles = true
navigation.tabBarItem = UITabBarItem( navigation.tabBarItem = UITabBarItem(
@@ -54,6 +69,7 @@ private final class AppContext: NSObject, UITabBarControllerDelegate {
tabs.viewControllers = navigationControllers tabs.viewControllers = navigationControllers
tabs.delegate = self tabs.delegate = self
restoreSelectedTab() restoreSelectedTab()
monitorAuthentication()
return tabs 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 @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<Void, Never>?
private var loadTask: Task<Void, Never>?
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<MobileAuthenticationState, AuthenticationFailure>.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 @MainActor
private final class PasswordDirectoryViewController: UITableViewController, MobileTabRoot { private final class PasswordDirectoryViewController: UITableViewController, MobileTabRoot {
fileprivate let shellTab = MobileTab.passwords fileprivate let shellTab = MobileTab.passwords
private let path: String? private let path: String?
private let authentication: MobileAuthentication?
private var shellPage: MobilePage? private var shellPage: MobilePage?
private var directoryPage: MobilePasswordPage? private var directoryPage: MobilePasswordPage?
private var loadTask: Task<Void, Never>? private var loadTask: Task<Void, Never>?
private var loadGeneration = 0 private var loadGeneration = 0
init(shellPage: MobilePage, path: String? = nil) { init(
shellPage: MobilePage,
authentication: MobileAuthentication?,
path: String? = nil
) {
self.shellPage = shellPage self.shellPage = shellPage
self.authentication = authentication
self.path = path self.path = path
super.init(style: .insetGrouped) super.init(style: .insetGrouped)
title = shellPage.title 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.path = path
self.authentication = authentication
shellPage = nil shellPage = nil
super.init(style: .insetGrouped) super.init(style: .insetGrouped)
self.title = title self.title = title
@@ -719,9 +987,13 @@ private final class PasswordDirectoryViewController: UITableViewController, Mobi
guard let row = directoryPage?.rows[indexPath.row] else { return } guard let row = directoryPage?.rows[indexPath.row] else { return }
let controller: UIViewController = switch row.kind { let controller: UIViewController = switch row.kind {
case .directory: case .directory:
PasswordDirectoryViewController(path: row.path, title: row.title) PasswordDirectoryViewController(
path: row.path,
title: row.title,
authentication: authentication
)
case .entry: case .entry:
LockedPasswordViewController(entry: row) LockedPasswordViewController(entry: row, authentication: authentication)
} }
navigationController?.pushViewController(controller, animated: true) navigationController?.pushViewController(controller, animated: true)
} }
@@ -872,9 +1144,17 @@ private struct PasswordFailure: Error, Sendable {
@MainActor @MainActor
private final class LockedPasswordViewController: UIViewController { private final class LockedPasswordViewController: UIViewController {
private let entry: MobilePasswordRow 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<Void, Never>?
private var state: MobileAuthenticationState?
init(entry: MobilePasswordRow) { init(entry: MobilePasswordRow, authentication: MobileAuthentication?) {
self.entry = entry self.entry = entry
self.authentication = authentication
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
title = entry.title title = entry.title
navigationItem.largeTitleDisplayMode = .never navigationItem.largeTitleDisplayMode = .never
@@ -888,12 +1168,230 @@ private final class LockedPasswordViewController: UIViewController {
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
view.backgroundColor = .systemGroupedBackground view.backgroundColor = .systemGroupedBackground
var configuration = UIContentUnavailableConfiguration.empty() imageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 44)
configuration.image = UIImage(systemName: "lock.fill") imageView.tintColor = .secondaryLabel
configuration.text = "Locked Password" titleLabel.font = .preferredFont(forTextStyle: .title2)
configuration.secondaryText = "Authenticate to reveal this password entry." titleLabel.adjustsFontForContentSizeCategory = true
contentUnavailableConfiguration = configuration 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 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<MobileAuthenticationState, AuthenticationFailure>.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)
} }
} }

View File

@@ -22,6 +22,7 @@ targets:
properties: properties:
CFBundleDisplayName: IronStorage CFBundleDisplayName: IronStorage
ITSAppUsesNonExemptEncryption: false ITSAppUsesNonExemptEncryption: false
NSFaceIDUsageDescription: Unlock your GPG key for protected password operations.
UILaunchScreen: {} UILaunchScreen: {}
UISupportedInterfaceOrientations: UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait - UIInterfaceOrientationPortrait

View File

@@ -8,6 +8,11 @@ use std::{error::Error, fmt, sync::Arc};
use ironstorage::{ use ironstorage::{
config::ConfigError, config::ConfigError,
mobile::{self, MobileShellState as StorageShellState, MobileTab as StorageTab}, mobile::{self, MobileShellState as StorageShellState, MobileTab as StorageTab},
mobile_authentication::{
MobileAuthenticationError as StorageAuthenticationError,
MobileAuthenticationErrorKind as StorageAuthenticationErrorKind,
MobileAuthenticationState as StorageAuthenticationState,
},
mobile_home::{ mobile_home::{
self, MobileHomeChangeKind as StorageHomeChangeKind, self, MobileHomeChangeKind as StorageHomeChangeKind,
MobileHomeChangeStatus as StorageHomeChangeStatus, MobileHomeError as StorageHomeError, MobileHomeChangeStatus as StorageHomeChangeStatus, MobileHomeError as StorageHomeError,
@@ -491,6 +496,135 @@ impl From<StoragePasswordError> for MobilePasswordFfiError {
} }
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
pub enum MobileAuthenticationErrorKind {
PassphraseRequired,
InvalidPassphrase,
Cancelled,
BiometryUnavailable,
Configuration,
KeyMaterial,
Entry,
SecureStorage,
Expired,
}
impl From<StorageAuthenticationErrorKind> 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<StorageAuthenticationState> 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<StorageAuthenticationError> 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<MobileAuthenticationState, MobileAuthenticationFfiError> {
self.authentication
.state()
.map(Into::into)
.map_err(Into::into)
}
pub fn unlock_entry(
&self,
path: String,
passphrase: Option<String>,
) -> Result<MobileAuthenticationState, MobileAuthenticationFfiError> {
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<MobileAuthenticationState, MobileAuthenticationFfiError> {
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)] #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
pub enum MobileOnboardingPhase { pub enum MobileOnboardingPhase {
Validating, Validating,
@@ -711,6 +845,13 @@ pub fn mobile_password_page(
.map_err(Into::into) .map_err(Into::into)
} }
#[uniffi::export]
pub fn mobile_authentication() -> Result<Arc<MobileAuthentication>, MobileAuthenticationFfiError> {
Ok(Arc::new(MobileAuthentication {
authentication: ironstorage::mobile_authentication::MobileAuthentication::load()?,
}))
}
#[uniffi::export] #[uniffi::export]
pub fn mobile_onboarding_operation( pub fn mobile_onboarding_operation(
server_url: String, server_url: String,

View File

@@ -227,36 +227,7 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> AuthenticationSession<B, C>
} }
let _operation = self.shared.operation()?; let _operation = self.shared.operation()?;
self.shared.expire_if_needed()?; self.shared.expire_if_needed()?;
let now = self.shared.clock.now(); let generation = self.shared.begin_lease()?;
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());
}
if key.requires_passphrase() if key.requires_passphrase()
&& let Err(error) = self.shared.cache_key_passphrase(generation, key) && let Err(error) = self.shared.cache_key_passphrase(generation, key)
@@ -273,6 +244,40 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> AuthenticationSession<B, C>
}) })
} }
/// Establish a lease from a manually entered passphrase without persisting it.
pub fn authenticate_with_passphrase(
&self,
key: &KeyInfo,
passphrase: SecretBytes,
) -> Result<AuthenticationHandle<B, C>, 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. /// Remaining active time. Calling this from a timer never extends the lease.
pub fn remaining_time(&self) -> Result<Option<Duration>, AuthenticationError> { pub fn remaining_time(&self) -> Result<Option<Duration>, AuthenticationError> {
let _operation = self.shared.operation()?; let _operation = self.shared.operation()?;
@@ -360,6 +365,25 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> AuthenticationHandle<B, C> {
active.deadline.saturating_sub(now) 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<B: SecretStoreBackend, C: AuthenticationClock> SecretProvider for AuthenticationHandle<B, C> { impl<B: SecretStoreBackend, C: AuthenticationClock> SecretProvider for AuthenticationHandle<B, C> {
@@ -408,6 +432,37 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> Shared<B, C> {
.map_err(|_| AuthenticationError::SecretStore(SecretStoreError::Unavailable)) .map_err(|_| AuthenticationError::SecretStore(SecretStoreError::Unavailable))
} }
fn begin_lease(&self) -> Result<u64, AuthenticationError> {
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<bool, AuthenticationError> { fn expire_if_needed(&self) -> Result<bool, AuthenticationError> {
let now = self.clock.now(); let now = self.clock.now();
let expired = self let expired = self

View File

@@ -35,6 +35,7 @@ pub struct Config {
editor: Option<EditorCommand>, editor: Option<EditorCommand>,
clipboard_timeout: ClipboardTimeout, clipboard_timeout: ClipboardTimeout,
authentication_timeout: AuthenticationTimeout, authentication_timeout: AuthenticationTimeout,
biometric_unlock_enabled: bool,
mobile_tab: MobileTab, mobile_tab: MobileTab,
mobile_home_refreshed_at: Option<i64>, mobile_home_refreshed_at: Option<i64>,
git_remotes: Vec<GitRemote>, git_remotes: Vec<GitRemote>,
@@ -122,6 +123,10 @@ impl Config {
self.authentication_timeout self.authentication_timeout
} }
pub fn biometric_unlock_enabled(&self) -> bool {
self.biometric_unlock_enabled
}
pub fn mobile_tab(&self) -> MobileTab { pub fn mobile_tab(&self) -> MobileTab {
self.mobile_tab self.mobile_tab
} }
@@ -203,6 +208,31 @@ impl Config {
validate_config(self.source.clone(), document, raw)?.persist() 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::<RawConfig>()
.map_err(|_| ConfigError::Malformed {
path: self.source.clone(),
})?;
validate_config(self.source.clone(), document, raw)?.persist()
}
pub(crate) fn create_mobile_clone( pub(crate) fn create_mobile_clone(
source: PathBuf, source: PathBuf,
vault: &Path, vault: &Path,
@@ -835,6 +865,7 @@ struct RawConfig {
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
struct RawSecurity { struct RawSecurity {
inactivity_timeout_seconds: Option<u64>, inactivity_timeout_seconds: Option<u64>,
biometric_unlock_enabled: Option<bool>,
} }
#[derive(Default, Deserialize)] #[derive(Default, Deserialize)]
@@ -916,6 +947,7 @@ fn validate_config(
.map_err(|_| ConfigError::InvalidField { .map_err(|_| ConfigError::InvalidField {
field: "security.inactivity_timeout_seconds", field: "security.inactivity_timeout_seconds",
})?; })?;
let biometric_unlock_enabled = raw.security.biometric_unlock_enabled.unwrap_or(false);
let mobile_tab = raw let mobile_tab = raw
.ui .ui
.selected_mobile_tab .selected_mobile_tab
@@ -943,6 +975,7 @@ fn validate_config(
editor, editor,
clipboard_timeout, clipboard_timeout,
authentication_timeout, authentication_timeout,
biometric_unlock_enabled,
mobile_tab, mobile_tab,
mobile_home_refreshed_at, mobile_home_refreshed_at,
git_remotes, 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 { let security = security.as_table().ok_or_else(|| ConfigError::Malformed {
path: source.to_owned(), 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") { if let Some(ui) = root.get("ui") {
let ui = ui.as_table().ok_or_else(|| ConfigError::Malformed { let ui = ui.as_table().ok_or_else(|| ConfigError::Malformed {

View File

@@ -407,6 +407,27 @@ impl KeyStore {
Ok(actual == expected) Ok(actual == expected)
} }
/// Return the secret keys named by a pass entry's PKESK packets.
pub fn decrypting_keys(
&self,
ciphertext: &EncryptedEntry,
) -> Result<Vec<KeyInfo>, 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::<Vec<_>>();
if keys.is_empty() {
return Err(CryptoError::MissingSecretKey);
}
Ok(keys)
}
/// Decrypt a pass entry with only the secret keys named by its PKESK packets. /// Decrypt a pass entry with only the secret keys named by its PKESK packets.
pub fn decrypt( pub fn decrypt(
&self, &self,
@@ -418,16 +439,7 @@ impl KeyStore {
if !message.is_encrypted() { if !message.is_encrypted() {
return Err(CryptoError::CorruptMessage); return Err(CryptoError::CorruptMessage);
} }
let candidates = self let candidates = self.decrypting_material(&message);
.keys
.iter()
.filter(|(_, material)| {
material
.secret
.as_ref()
.is_some_and(|secret| message_matches_secret(&message, secret))
})
.collect::<Vec<_>>();
if candidates.is_empty() { if candidates.is_empty() {
return Err(CryptoError::MissingSecretKey); 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( pub fn sign(
&self, &self,
data: &[u8], data: &[u8],

View File

@@ -15,6 +15,7 @@ pub mod generate;
pub mod git; pub mod git;
pub mod kdbx; pub mod kdbx;
pub mod mobile; pub mod mobile;
pub mod mobile_authentication;
pub mod mobile_home; pub mod mobile_home;
pub mod mobile_onboarding; pub mod mobile_onboarding;
pub mod mobile_passwords; pub mod mobile_passwords;

View File

@@ -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<String>) -> Self {
Self {
kind,
title: title.to_owned(),
detail: detail.into(),
}
}
fn passphrase_required(detail: impl Into<String>) -> 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<ActiveMobileLease>,
}
/// 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<MobileAuthenticationStatus>,
}
impl MobileAuthentication {
pub fn load() -> Result<Self, MobileAuthenticationError> {
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<SecretBytes>,
) -> Result<MobileAuthenticationState, MobileAuthenticationError> {
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<MobileAuthenticationState, MobileAuthenticationError> {
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<MobileAuthenticationState, MobileAuthenticationError> {
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<std::sync::MutexGuard<'_, MobileAuthenticationStatus>, MobileAuthenticationError>
{
self.status.lock().map_err(|_| {
MobileAuthenticationError::new(
MobileAuthenticationErrorKind::SecureStorage,
"Authentication Is Unavailable",
"The shared authentication state is unavailable.",
)
})
}
fn take_active(&self) -> Result<Option<ActiveMobileLease>, 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<SecretBytes, SecretProviderError> {
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(),
)
}

View File

@@ -170,6 +170,7 @@ impl SecretLocator {
pub enum SecretProtection { pub enum SecretProtection {
DeviceUnlocked, DeviceUnlocked,
RequireUserPresence, RequireUserPresence,
BiometryCurrentSet,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[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 { fn for_reference(self, reference: &SecretReference) -> SecretProtection {
match &reference.kind { match &reference.kind {
SecretReferenceKind::OpenPgpPassphrase { .. } => self.openpgp, SecretReferenceKind::OpenPgpPassphrase { .. } => self.openpgp,
@@ -474,6 +482,40 @@ impl<B: SecretStoreBackend> SecretStore<B> {
Ok(()) 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 /// Create or replace one HTTPS Git account/token record without exposing
/// the previously stored account or token to a frontend. /// the previously stored account or token to a frontend.
pub fn store_https_git_credential( pub fn store_https_git_credential(
@@ -525,6 +567,24 @@ impl<B: SecretStoreBackend> SecretStore<B> {
Ok(()) 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( fn retrieve_git_record(
&self, &self,
server: &ServerId, server: &ServerId,

View File

@@ -11,6 +11,15 @@ use std::sync::Arc;
#[cfg(any(target_os = "ios", target_os = "macos"))] #[cfg(any(target_os = "ios", target_os = "macos"))]
use std::collections::HashMap; 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 keyring_core::{CredentialStore, Entry};
use super::{SecretLocator, SecretProtection, SecretStoreBackend, SecretStoreError}; use super::{SecretLocator, SecretProtection, SecretStoreBackend, SecretStoreError};
@@ -77,7 +86,7 @@ impl NativeSecretBackend {
let (service, user) = locator.service_and_user(); let (service, user) = locator.service_and_user();
#[cfg(any(target_os = "linux", target_os = "windows"))] #[cfg(any(target_os = "linux", target_os = "windows"))]
{ {
if protection == SecretProtection::RequireUserPresence { if protection != SecretProtection::DeviceUnlocked {
return Err(SecretStoreError::UnsupportedProtection); return Err(SecretStoreError::UnsupportedProtection);
} }
self.store.build(service, &user, None).map_err(map_error) self.store.build(service, &user, None).map_err(map_error)
@@ -101,6 +110,7 @@ impl NativeSecretBackend {
.build(service, &user, modifiers.as_ref()) .build(service, &user, modifiers.as_ref())
.map_err(map_error) .map_err(map_error)
} }
SecretProtection::BiometryCurrentSet => Err(SecretStoreError::Unavailable),
} }
} }
#[cfg(not(any( #[cfg(not(any(
@@ -123,6 +133,10 @@ impl SecretStoreBackend for NativeSecretBackend {
protection: SecretProtection, protection: SecretProtection,
value: &[u8], value: &[u8],
) -> Result<(), SecretStoreError> { ) -> 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)?; let entry = self.entry(locator, protection)?;
match entry.get_secret() { match entry.get_secret() {
Ok(existing) => { Ok(existing) => {
@@ -139,6 +153,12 @@ impl SecretStoreBackend for NativeSecretBackend {
locator: &SecretLocator, locator: &SecretLocator,
protection: SecretProtection, protection: SecretProtection,
) -> Result<SecretBytes, SecretStoreError> { ) -> Result<SecretBytes, SecretStoreError> {
#[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)? self.entry(locator, protection)?
.get_secret() .get_secret()
.map(SecretBytes::new) .map(SecretBytes::new)
@@ -151,6 +171,11 @@ impl SecretStoreBackend for NativeSecretBackend {
protection: SecretProtection, protection: SecretProtection,
value: &[u8], value: &[u8],
) -> Result<(), SecretStoreError> { ) -> 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 entry = self.entry(locator, protection)?;
let existing = entry.get_secret().map_err(map_error)?; let existing = entry.get_secret().map_err(map_error)?;
drop(SecretBytes::new(existing)); drop(SecretBytes::new(existing));
@@ -162,6 +187,11 @@ impl SecretStoreBackend for NativeSecretBackend {
locator: &SecretLocator, locator: &SecretLocator,
protection: SecretProtection, protection: SecretProtection,
) -> Result<(), SecretStoreError> { ) -> 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)? self.entry(locator, protection)?
.delete_credential() .delete_credential()
.map_err(map_error) .map_err(map_error)
@@ -175,6 +205,43 @@ fn presence_modifiers(protection: SecretProtection) -> Option<HashMap<&'static s
SecretProtection::RequireUserPresence => { SecretProtection::RequireUserPresence => {
Some(HashMap::from([("access-policy", "require-user-presence")])) 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<PasswordOptions, SecretStoreError> {
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,
} }
} }

View File

@@ -33,6 +33,7 @@ struct BackendState {
retrieves: usize, retrieves: usize,
locks: usize, locks: usize,
unlocks: usize, unlocks: usize,
last_protection: Option<SecretProtection>,
} }
#[derive(Clone, Default)] #[derive(Clone, Default)]
@@ -51,6 +52,14 @@ impl MemoryBackend {
self.0.lock().expect("test mutex").locks self.0.lock().expect("test mutex").locks
} }
fn last_protection(&self) -> Option<SecretProtection> {
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> { fn take_fault(state: &mut BackendState) -> Result<(), SecretStoreError> {
match state.fault.take() { match state.fault.take() {
Some(error) => Err(error), Some(error) => Err(error),
@@ -63,11 +72,12 @@ impl SecretStoreBackend for MemoryBackend {
fn create( fn create(
&self, &self,
locator: &SecretLocator, locator: &SecretLocator,
_protection: SecretProtection, protection: SecretProtection,
value: &[u8], value: &[u8],
) -> Result<(), SecretStoreError> { ) -> Result<(), SecretStoreError> {
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?; let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
Self::take_fault(&mut state)?; Self::take_fault(&mut state)?;
state.last_protection = Some(protection);
if state.values.contains_key(locator) { if state.values.contains_key(locator) {
return Err(SecretStoreError::AlreadyExists); return Err(SecretStoreError::AlreadyExists);
} }
@@ -80,10 +90,11 @@ impl SecretStoreBackend for MemoryBackend {
fn retrieve( fn retrieve(
&self, &self,
locator: &SecretLocator, locator: &SecretLocator,
_protection: SecretProtection, protection: SecretProtection,
) -> Result<SecretBytes, SecretStoreError> { ) -> Result<SecretBytes, SecretStoreError> {
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?; let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
Self::take_fault(&mut state)?; Self::take_fault(&mut state)?;
state.last_protection = Some(protection);
state.retrieves += 1; state.retrieves += 1;
state state
.values .values
@@ -95,11 +106,12 @@ impl SecretStoreBackend for MemoryBackend {
fn replace( fn replace(
&self, &self,
locator: &SecretLocator, locator: &SecretLocator,
_protection: SecretProtection, protection: SecretProtection,
value: &[u8], value: &[u8],
) -> Result<(), SecretStoreError> { ) -> Result<(), SecretStoreError> {
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?; let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
Self::take_fault(&mut state)?; Self::take_fault(&mut state)?;
state.last_protection = Some(protection);
let existing = state let existing = state
.values .values
.get_mut(locator) .get_mut(locator)
@@ -111,10 +123,11 @@ impl SecretStoreBackend for MemoryBackend {
fn delete( fn delete(
&self, &self,
locator: &SecretLocator, locator: &SecretLocator,
_protection: SecretProtection, protection: SecretProtection,
) -> Result<(), SecretStoreError> { ) -> Result<(), SecretStoreError> {
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?; let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
Self::take_fault(&mut state)?; Self::take_fault(&mut state)?;
state.last_protection = Some(protection);
state state
.values .values
.remove(locator) .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"); assert_eq!(backend.locks(), baseline_locks + 1, "expiry relocks once");
Ok(()) 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(())
}

View File

@@ -210,6 +210,31 @@ fn mobile_tab_defaults_and_persists_through_storage_configuration() -> TestResul
Ok(()) 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] #[test]
fn desktop_vault_switch_preserves_and_reloads_the_shared_configuration() -> TestResult { fn desktop_vault_switch_preserves_and_reloads_the_shared_configuration() -> TestResult {
let fixture = ConfigurationFixture::new()?; let fixture = ConfigurationFixture::new()?;