Implement biometric-protected GPG unlock
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
<string>1</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>Unlock your GPG key for protected password operations.</string>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
|
||||
@@ -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<Void, Never>?
|
||||
private var authenticationMonitor: Task<Void, Never>?
|
||||
|
||||
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<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
|
||||
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<Void, Never>?
|
||||
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<Void, Never>?
|
||||
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<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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ targets:
|
||||
properties:
|
||||
CFBundleDisplayName: IronStorage
|
||||
ITSAppUsesNonExemptEncryption: false
|
||||
NSFaceIDUsageDescription: Unlock your GPG key for protected password operations.
|
||||
UILaunchScreen: {}
|
||||
UISupportedInterfaceOrientations:
|
||||
- UIInterfaceOrientationPortrait
|
||||
|
||||
@@ -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<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)]
|
||||
pub enum MobileOnboardingPhase {
|
||||
Validating,
|
||||
@@ -711,6 +845,13 @@ pub fn mobile_password_page(
|
||||
.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]
|
||||
pub fn mobile_onboarding_operation(
|
||||
server_url: String,
|
||||
|
||||
@@ -227,36 +227,7 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> AuthenticationSession<B, C>
|
||||
}
|
||||
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<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.
|
||||
pub fn remaining_time(&self) -> Result<Option<Duration>, AuthenticationError> {
|
||||
let _operation = self.shared.operation()?;
|
||||
@@ -360,6 +365,25 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> AuthenticationHandle<B, C> {
|
||||
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> {
|
||||
@@ -408,6 +432,37 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> Shared<B, C> {
|
||||
.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> {
|
||||
let now = self.clock.now();
|
||||
let expired = self
|
||||
|
||||
@@ -35,6 +35,7 @@ pub struct Config {
|
||||
editor: Option<EditorCommand>,
|
||||
clipboard_timeout: ClipboardTimeout,
|
||||
authentication_timeout: AuthenticationTimeout,
|
||||
biometric_unlock_enabled: bool,
|
||||
mobile_tab: MobileTab,
|
||||
mobile_home_refreshed_at: Option<i64>,
|
||||
git_remotes: Vec<GitRemote>,
|
||||
@@ -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::<RawConfig>()
|
||||
.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<u64>,
|
||||
biometric_unlock_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[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 {
|
||||
|
||||
@@ -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<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.
|
||||
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::<Vec<_>>();
|
||||
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],
|
||||
|
||||
@@ -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;
|
||||
|
||||
433
crates/storage/src/mobile_authentication.rs
Normal file
433
crates/storage/src/mobile_authentication.rs
Normal 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(),
|
||||
)
|
||||
}
|
||||
@@ -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<B: SecretStoreBackend> SecretStore<B> {
|
||||
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<B: SecretStoreBackend> SecretStore<B> {
|
||||
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,
|
||||
|
||||
@@ -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<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)?
|
||||
.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<HashMap<&'static s
|
||||
SecretProtection::RequireUserPresence => {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ struct BackendState {
|
||||
retrieves: usize,
|
||||
locks: usize,
|
||||
unlocks: usize,
|
||||
last_protection: Option<SecretProtection>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
@@ -51,6 +52,14 @@ impl MemoryBackend {
|
||||
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> {
|
||||
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<SecretBytes, SecretStoreError> {
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
Reference in New Issue
Block a user