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)
|
#if swift(>=5.8)
|
||||||
@_documentation(visibility: private)
|
@_documentation(visibility: private)
|
||||||
#endif
|
#endif
|
||||||
@@ -567,6 +583,178 @@ fileprivate struct FfiConverterString: FfiConverter {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
public protocol MobileAuthenticationProtocol: AnyObject, Sendable {
|
||||||
|
|
||||||
|
func cancel() throws
|
||||||
|
|
||||||
|
func manualLock() throws
|
||||||
|
|
||||||
|
func setBiometricUnlock(enabled: Bool) throws -> MobileAuthenticationState
|
||||||
|
|
||||||
|
func state() throws -> MobileAuthenticationState
|
||||||
|
|
||||||
|
func touchUserActivity() throws
|
||||||
|
|
||||||
|
func unlockEntry(path: String, passphrase: String?) throws -> MobileAuthenticationState
|
||||||
|
|
||||||
|
}
|
||||||
|
open class MobileAuthentication: MobileAuthenticationProtocol, @unchecked Sendable {
|
||||||
|
fileprivate let handle: UInt64
|
||||||
|
|
||||||
|
/// Used to instantiate a [FFIObject] without an actual handle, for fakes in tests, mostly.
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public struct NoHandle {
|
||||||
|
public init() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: We'd like this to be `private` but for Swifty reasons,
|
||||||
|
// we can't implement `FfiConverter` without making this `required` and we can't
|
||||||
|
// make it `required` without making it `public`.
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
required public init(unsafeFromHandle handle: UInt64) {
|
||||||
|
self.handle = handle
|
||||||
|
}
|
||||||
|
|
||||||
|
// This constructor can be used to instantiate a fake object.
|
||||||
|
// - Parameter noHandle: Placeholder value so we can have a constructor separate from the default empty one that may be implemented for classes extending [FFIObject].
|
||||||
|
//
|
||||||
|
// - Warning:
|
||||||
|
// Any object instantiated with this constructor cannot be passed to an actual Rust-backed object. Since there isn't a backing handle the FFI lower functions will crash.
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public init(noHandle: NoHandle) {
|
||||||
|
self.handle = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public func uniffiCloneHandle() -> UInt64 {
|
||||||
|
return try! rustCall { uniffi_ironstorage_apple_fn_clone_mobileauthentication(self.handle, $0) }
|
||||||
|
}
|
||||||
|
// No primary constructor declared for this class.
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
if handle == 0 {
|
||||||
|
// Mock objects have handle=0 don't try to free them
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try! rustCall { uniffi_ironstorage_apple_fn_free_mobileauthentication(handle, $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
open func cancel()throws {try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
||||||
|
uniffiCallStatus in
|
||||||
|
uniffi_ironstorage_apple_fn_method_mobileauthentication_cancel(
|
||||||
|
self.uniffiCloneHandle(),uniffiCallStatus
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
open func manualLock()throws {try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
||||||
|
uniffiCallStatus in
|
||||||
|
uniffi_ironstorage_apple_fn_method_mobileauthentication_manual_lock(
|
||||||
|
self.uniffiCloneHandle(),uniffiCallStatus
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
open func setBiometricUnlock(enabled: Bool)throws -> MobileAuthenticationState {
|
||||||
|
return try FfiConverterTypeMobileAuthenticationState_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
||||||
|
uniffiCallStatus in
|
||||||
|
uniffi_ironstorage_apple_fn_method_mobileauthentication_set_biometric_unlock(
|
||||||
|
self.uniffiCloneHandle(),
|
||||||
|
FfiConverterBool.lower(enabled),uniffiCallStatus
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
open func state()throws -> MobileAuthenticationState {
|
||||||
|
return try FfiConverterTypeMobileAuthenticationState_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
||||||
|
uniffiCallStatus in
|
||||||
|
uniffi_ironstorage_apple_fn_method_mobileauthentication_state(
|
||||||
|
self.uniffiCloneHandle(),uniffiCallStatus
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
open func touchUserActivity()throws {try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
||||||
|
uniffiCallStatus in
|
||||||
|
uniffi_ironstorage_apple_fn_method_mobileauthentication_touch_user_activity(
|
||||||
|
self.uniffiCloneHandle(),uniffiCallStatus
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
open func unlockEntry(path: String, passphrase: String?)throws -> MobileAuthenticationState {
|
||||||
|
return try FfiConverterTypeMobileAuthenticationState_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
||||||
|
uniffiCallStatus in
|
||||||
|
uniffi_ironstorage_apple_fn_method_mobileauthentication_unlock_entry(
|
||||||
|
self.uniffiCloneHandle(),
|
||||||
|
FfiConverterString.lower(path),
|
||||||
|
FfiConverterOptionString.lower(passphrase),uniffiCallStatus
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public struct FfiConverterTypeMobileAuthentication: FfiConverter {
|
||||||
|
typealias FfiType = UInt64
|
||||||
|
typealias SwiftType = MobileAuthentication
|
||||||
|
|
||||||
|
public static func lift(_ handle: UInt64) throws -> MobileAuthentication {
|
||||||
|
return MobileAuthentication(unsafeFromHandle: handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func lower(_ value: MobileAuthentication) -> UInt64 {
|
||||||
|
return value.uniffiCloneHandle()
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileAuthentication {
|
||||||
|
let handle: UInt64 = try readInt(&buf)
|
||||||
|
return try lift(handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func write(_ value: MobileAuthentication, into buf: inout [UInt8]) {
|
||||||
|
writeInt(&buf, lower(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public func FfiConverterTypeMobileAuthentication_lift(_ handle: UInt64) throws -> MobileAuthentication {
|
||||||
|
return try FfiConverterTypeMobileAuthentication.lift(handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public func FfiConverterTypeMobileAuthentication_lower(_ value: MobileAuthentication) -> UInt64 {
|
||||||
|
return FfiConverterTypeMobileAuthentication.lower(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public protocol MobileHomeOperationProtocol: AnyObject, Sendable {
|
public protocol MobileHomeOperationProtocol: AnyObject, Sendable {
|
||||||
|
|
||||||
func cached() throws -> MobileHomePage
|
func cached() throws -> MobileHomePage
|
||||||
@@ -887,6 +1075,64 @@ public func FfiConverterTypeMobileOnboardingOperation_lower(_ value: MobileOnboa
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
public struct MobileAuthenticationState: Equatable, Hashable {
|
||||||
|
public var unlocked: Bool
|
||||||
|
public var biometricUnlockEnabled: Bool
|
||||||
|
public var remainingSeconds: UInt64
|
||||||
|
|
||||||
|
// Default memberwise initializers are never public by default, so we
|
||||||
|
// declare one manually.
|
||||||
|
public init(unlocked: Bool, biometricUnlockEnabled: Bool, remainingSeconds: UInt64) {
|
||||||
|
self.unlocked = unlocked
|
||||||
|
self.biometricUnlockEnabled = biometricUnlockEnabled
|
||||||
|
self.remainingSeconds = remainingSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#if compiler(>=6)
|
||||||
|
extension MobileAuthenticationState: Sendable {}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public struct FfiConverterTypeMobileAuthenticationState: FfiConverterRustBuffer {
|
||||||
|
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileAuthenticationState {
|
||||||
|
return
|
||||||
|
try MobileAuthenticationState(
|
||||||
|
unlocked: FfiConverterBool.read(from: &buf),
|
||||||
|
biometricUnlockEnabled: FfiConverterBool.read(from: &buf),
|
||||||
|
remainingSeconds: FfiConverterUInt64.read(from: &buf)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func write(_ value: MobileAuthenticationState, into buf: inout [UInt8]) {
|
||||||
|
FfiConverterBool.write(value.unlocked, into: &buf)
|
||||||
|
FfiConverterBool.write(value.biometricUnlockEnabled, into: &buf)
|
||||||
|
FfiConverterUInt64.write(value.remainingSeconds, into: &buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public func FfiConverterTypeMobileAuthenticationState_lift(_ buf: RustBuffer) throws -> MobileAuthenticationState {
|
||||||
|
return try FfiConverterTypeMobileAuthenticationState.lift(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public func FfiConverterTypeMobileAuthenticationState_lower(_ value: MobileAuthenticationState) -> RustBuffer {
|
||||||
|
return FfiConverterTypeMobileAuthenticationState.lower(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public struct MobileHomeChange: Equatable, Hashable {
|
public struct MobileHomeChange: Equatable, Hashable {
|
||||||
public var id: String
|
public var id: String
|
||||||
public var title: String
|
public var title: String
|
||||||
@@ -1710,6 +1956,200 @@ public func FfiConverterTypeMobileShell_lower(_ value: MobileShell) -> RustBuffe
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
public enum MobileAuthenticationErrorKind: Equatable, Hashable {
|
||||||
|
|
||||||
|
case passphraseRequired
|
||||||
|
case invalidPassphrase
|
||||||
|
case cancelled
|
||||||
|
case biometryUnavailable
|
||||||
|
case configuration
|
||||||
|
case keyMaterial
|
||||||
|
case entry
|
||||||
|
case secureStorage
|
||||||
|
case expired
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#if compiler(>=6)
|
||||||
|
extension MobileAuthenticationErrorKind: Sendable {}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public struct FfiConverterTypeMobileAuthenticationErrorKind: FfiConverterRustBuffer {
|
||||||
|
typealias SwiftType = MobileAuthenticationErrorKind
|
||||||
|
|
||||||
|
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileAuthenticationErrorKind {
|
||||||
|
let variant: Int32 = try readInt(&buf)
|
||||||
|
switch variant {
|
||||||
|
|
||||||
|
case 1: return .passphraseRequired
|
||||||
|
|
||||||
|
case 2: return .invalidPassphrase
|
||||||
|
|
||||||
|
case 3: return .cancelled
|
||||||
|
|
||||||
|
case 4: return .biometryUnavailable
|
||||||
|
|
||||||
|
case 5: return .configuration
|
||||||
|
|
||||||
|
case 6: return .keyMaterial
|
||||||
|
|
||||||
|
case 7: return .entry
|
||||||
|
|
||||||
|
case 8: return .secureStorage
|
||||||
|
|
||||||
|
case 9: return .expired
|
||||||
|
|
||||||
|
default: throw UniffiInternalError.unexpectedEnumCase
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func write(_ value: MobileAuthenticationErrorKind, into buf: inout [UInt8]) {
|
||||||
|
switch value {
|
||||||
|
|
||||||
|
|
||||||
|
case .passphraseRequired:
|
||||||
|
writeInt(&buf, Int32(1))
|
||||||
|
|
||||||
|
|
||||||
|
case .invalidPassphrase:
|
||||||
|
writeInt(&buf, Int32(2))
|
||||||
|
|
||||||
|
|
||||||
|
case .cancelled:
|
||||||
|
writeInt(&buf, Int32(3))
|
||||||
|
|
||||||
|
|
||||||
|
case .biometryUnavailable:
|
||||||
|
writeInt(&buf, Int32(4))
|
||||||
|
|
||||||
|
|
||||||
|
case .configuration:
|
||||||
|
writeInt(&buf, Int32(5))
|
||||||
|
|
||||||
|
|
||||||
|
case .keyMaterial:
|
||||||
|
writeInt(&buf, Int32(6))
|
||||||
|
|
||||||
|
|
||||||
|
case .entry:
|
||||||
|
writeInt(&buf, Int32(7))
|
||||||
|
|
||||||
|
|
||||||
|
case .secureStorage:
|
||||||
|
writeInt(&buf, Int32(8))
|
||||||
|
|
||||||
|
|
||||||
|
case .expired:
|
||||||
|
writeInt(&buf, Int32(9))
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public func FfiConverterTypeMobileAuthenticationErrorKind_lift(_ buf: RustBuffer) throws -> MobileAuthenticationErrorKind {
|
||||||
|
return try FfiConverterTypeMobileAuthenticationErrorKind.lift(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public func FfiConverterTypeMobileAuthenticationErrorKind_lower(_ value: MobileAuthenticationErrorKind) -> RustBuffer {
|
||||||
|
return FfiConverterTypeMobileAuthenticationErrorKind.lower(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
public
|
||||||
|
enum MobileAuthenticationFfiError: Swift.Error, Equatable, Hashable, Foundation.LocalizedError {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
case Failed(kind: MobileAuthenticationErrorKind, title: String, detail: String
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
String(reflecting: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#if compiler(>=6)
|
||||||
|
extension MobileAuthenticationFfiError: Sendable {}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public struct FfiConverterTypeMobileAuthenticationFfiError: FfiConverterRustBuffer {
|
||||||
|
typealias SwiftType = MobileAuthenticationFfiError
|
||||||
|
|
||||||
|
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileAuthenticationFfiError {
|
||||||
|
let variant: Int32 = try readInt(&buf)
|
||||||
|
switch variant {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
case 1: return .Failed(
|
||||||
|
kind: try FfiConverterTypeMobileAuthenticationErrorKind.read(from: &buf),
|
||||||
|
title: try FfiConverterString.read(from: &buf),
|
||||||
|
detail: try FfiConverterString.read(from: &buf)
|
||||||
|
)
|
||||||
|
|
||||||
|
default: throw UniffiInternalError.unexpectedEnumCase
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func write(_ value: MobileAuthenticationFfiError, into buf: inout [UInt8]) {
|
||||||
|
switch value {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
case let .Failed(kind,title,detail):
|
||||||
|
writeInt(&buf, Int32(1))
|
||||||
|
FfiConverterTypeMobileAuthenticationErrorKind.write(kind, into: &buf)
|
||||||
|
FfiConverterString.write(title, into: &buf)
|
||||||
|
FfiConverterString.write(detail, into: &buf)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public func FfiConverterTypeMobileAuthenticationFfiError_lift(_ buf: RustBuffer) throws -> MobileAuthenticationFfiError {
|
||||||
|
return try FfiConverterTypeMobileAuthenticationFfiError.lift(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public func FfiConverterTypeMobileAuthenticationFfiError_lower(_ value: MobileAuthenticationFfiError) -> RustBuffer {
|
||||||
|
return FfiConverterTypeMobileAuthenticationFfiError.lower(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public enum MobileHomeChangeKind: Equatable, Hashable {
|
public enum MobileHomeChangeKind: Equatable, Hashable {
|
||||||
|
|
||||||
case passwordEntry
|
case passwordEntry
|
||||||
@@ -3198,6 +3638,13 @@ fileprivate struct FfiConverterSequenceTypeMobilePasswordRow: FfiConverterRustBu
|
|||||||
return seq
|
return seq
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
public func mobileAuthentication()throws -> MobileAuthentication {
|
||||||
|
return try FfiConverterTypeMobileAuthentication_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
||||||
|
uniffiCallStatus in
|
||||||
|
uniffi_ironstorage_apple_fn_func_mobile_authentication(uniffiCallStatus
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
public func mobileHomeOperation() -> MobileHomeOperation {
|
public func mobileHomeOperation() -> MobileHomeOperation {
|
||||||
return try! FfiConverterTypeMobileHomeOperation_lift(try! rustCall() {
|
return try! FfiConverterTypeMobileHomeOperation_lift(try! rustCall() {
|
||||||
uniffiCallStatus in
|
uniffiCallStatus in
|
||||||
@@ -3277,6 +3724,9 @@ private let initializationResult: InitializationResult = {
|
|||||||
if bindings_contract_version != scaffolding_contract_version {
|
if bindings_contract_version != scaffolding_contract_version {
|
||||||
return InitializationResult.contractVersionMismatch
|
return InitializationResult.contractVersionMismatch
|
||||||
}
|
}
|
||||||
|
if (uniffi_ironstorage_apple_checksum_func_mobile_authentication() != 38258) {
|
||||||
|
return InitializationResult.apiChecksumMismatch
|
||||||
|
}
|
||||||
if (uniffi_ironstorage_apple_checksum_func_mobile_home_operation() != 10595) {
|
if (uniffi_ironstorage_apple_checksum_func_mobile_home_operation() != 10595) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
return InitializationResult.apiChecksumMismatch
|
||||||
}
|
}
|
||||||
@@ -3301,6 +3751,24 @@ private let initializationResult: InitializationResult = {
|
|||||||
if (uniffi_ironstorage_apple_checksum_func_set_selected_mobile_tab() != 65280) {
|
if (uniffi_ironstorage_apple_checksum_func_set_selected_mobile_tab() != 65280) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
return InitializationResult.apiChecksumMismatch
|
||||||
}
|
}
|
||||||
|
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_cancel() != 6512) {
|
||||||
|
return InitializationResult.apiChecksumMismatch
|
||||||
|
}
|
||||||
|
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_manual_lock() != 57220) {
|
||||||
|
return InitializationResult.apiChecksumMismatch
|
||||||
|
}
|
||||||
|
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_biometric_unlock() != 9486) {
|
||||||
|
return InitializationResult.apiChecksumMismatch
|
||||||
|
}
|
||||||
|
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_state() != 60826) {
|
||||||
|
return InitializationResult.apiChecksumMismatch
|
||||||
|
}
|
||||||
|
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_touch_user_activity() != 18402) {
|
||||||
|
return InitializationResult.apiChecksumMismatch
|
||||||
|
}
|
||||||
|
if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_unlock_entry() != 28139) {
|
||||||
|
return InitializationResult.apiChecksumMismatch
|
||||||
|
}
|
||||||
if (uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_cached() != 32436) {
|
if (uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_cached() != 32436) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
return InitializationResult.apiChecksumMismatch
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -242,6 +242,46 @@ typedef struct UniffiForeignFutureResultVoid {
|
|||||||
typedef void (*UniffiForeignFutureCompleteVoid)(uint64_t, UniffiForeignFutureResultVoid
|
typedef void (*UniffiForeignFutureCompleteVoid)(uint64_t, UniffiForeignFutureResultVoid
|
||||||
);
|
);
|
||||||
|
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEAUTHENTICATION
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEAUTHENTICATION
|
||||||
|
uint64_t uniffi_ironstorage_apple_fn_clone_mobileauthentication(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FREE_MOBILEAUTHENTICATION
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FREE_MOBILEAUTHENTICATION
|
||||||
|
void uniffi_ironstorage_apple_fn_free_mobileauthentication(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_CANCEL
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_CANCEL
|
||||||
|
void uniffi_ironstorage_apple_fn_method_mobileauthentication_cancel(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_MANUAL_LOCK
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_MANUAL_LOCK
|
||||||
|
void uniffi_ironstorage_apple_fn_method_mobileauthentication_manual_lock(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK
|
||||||
|
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_set_biometric_unlock(uint64_t ptr, int8_t enabled, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_STATE
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_STATE
|
||||||
|
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_state(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_TOUCH_USER_ACTIVITY
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_TOUCH_USER_ACTIVITY
|
||||||
|
void uniffi_ironstorage_apple_fn_method_mobileauthentication_touch_user_activity(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_UNLOCK_ENTRY
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_UNLOCK_ENTRY
|
||||||
|
RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_unlock_entry(uint64_t ptr, RustBuffer path, RustBuffer passphrase, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEHOMEOPERATION
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEHOMEOPERATION
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEHOMEOPERATION
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEHOMEOPERATION
|
||||||
@@ -311,6 +351,12 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobileonboardingoperation_progress
|
|||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_SETUP
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_SETUP
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_SETUP
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEONBOARDINGOPERATION_SETUP
|
||||||
RustBuffer uniffi_ironstorage_apple_fn_method_mobileonboardingoperation_setup(uint64_t ptr, RustBuffer branch, int8_t use_existing, RustCallStatus *_Nonnull out_status
|
RustBuffer uniffi_ironstorage_apple_fn_method_mobileonboardingoperation_setup(uint64_t ptr, RustBuffer branch, int8_t use_existing, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_AUTHENTICATION
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_AUTHENTICATION
|
||||||
|
uint64_t uniffi_ironstorage_apple_fn_func_mobile_authentication(RustCallStatus *_Nonnull out_status
|
||||||
|
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_HOME_OPERATION
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_FUNC_MOBILE_HOME_OPERATION
|
||||||
@@ -614,6 +660,12 @@ void ffi_ironstorage_apple_rust_future_free_void(uint64_t handle
|
|||||||
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_APPLE_RUST_FUTURE_COMPLETE_VOID
|
#ifndef UNIFFI_FFIDEF_FFI_IRONSTORAGE_APPLE_RUST_FUTURE_COMPLETE_VOID
|
||||||
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_APPLE_RUST_FUTURE_COMPLETE_VOID
|
#define UNIFFI_FFIDEF_FFI_IRONSTORAGE_APPLE_RUST_FUTURE_COMPLETE_VOID
|
||||||
void ffi_ironstorage_apple_rust_future_complete_void(uint64_t handle, RustCallStatus *_Nonnull out_status
|
void ffi_ironstorage_apple_rust_future_complete_void(uint64_t handle, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_AUTHENTICATION
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_AUTHENTICATION
|
||||||
|
uint16_t uniffi_ironstorage_apple_checksum_func_mobile_authentication(void
|
||||||
|
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_HOME_OPERATION
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_MOBILE_HOME_OPERATION
|
||||||
@@ -662,6 +714,42 @@ uint16_t uniffi_ironstorage_apple_checksum_func_replace_configured_mobile_applic
|
|||||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_SET_SELECTED_MOBILE_TAB
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_SET_SELECTED_MOBILE_TAB
|
||||||
uint16_t uniffi_ironstorage_apple_checksum_func_set_selected_mobile_tab(void
|
uint16_t uniffi_ironstorage_apple_checksum_func_set_selected_mobile_tab(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_CANCEL
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_CANCEL
|
||||||
|
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_cancel(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_MANUAL_LOCK
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_MANUAL_LOCK
|
||||||
|
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_manual_lock(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK
|
||||||
|
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_biometric_unlock(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_STATE
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_STATE
|
||||||
|
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_state(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_TOUCH_USER_ACTIVITY
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_TOUCH_USER_ACTIVITY
|
||||||
|
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_touch_user_activity(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_UNLOCK_ENTRY
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_UNLOCK_ENTRY
|
||||||
|
uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_unlock_entry(void
|
||||||
|
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_CACHED
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_CACHED
|
||||||
|
|||||||
@@ -22,6 +22,8 @@
|
|||||||
<string>1</string>
|
<string>1</string>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
<false/>
|
<false/>
|
||||||
|
<key>NSFaceIDUsageDescription</key>
|
||||||
|
<string>Unlock your GPG key for protected password operations.</string>
|
||||||
<key>UILaunchScreen</key>
|
<key>UILaunchScreen</key>
|
||||||
<dict/>
|
<dict/>
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ extension Notification.Name {
|
|||||||
static let ironStorageLocalStoreDidChange = Notification.Name(
|
static let ironStorageLocalStoreDidChange = Notification.Name(
|
||||||
"de.rfc1437.ironstorage.local-store-did-change"
|
"de.rfc1437.ironstorage.local-store-did-change"
|
||||||
)
|
)
|
||||||
|
static let ironStorageAuthenticationDidChange = Notification.Name(
|
||||||
|
"de.rfc1437.ironstorage.authentication-did-change"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@@ -33,15 +36,27 @@ private protocol MobileTabRoot: AnyObject {
|
|||||||
@MainActor
|
@MainActor
|
||||||
private final class AppContext: NSObject, UITabBarControllerDelegate {
|
private final class AppContext: NSObject, UITabBarControllerDelegate {
|
||||||
private let tabs = UITabBarController()
|
private let tabs = UITabBarController()
|
||||||
|
private let authentication = try? mobileAuthentication()
|
||||||
private var navigationControllers: [UINavigationController] = []
|
private var navigationControllers: [UINavigationController] = []
|
||||||
private var restoreTask: Task<Void, Never>?
|
private var restoreTask: Task<Void, Never>?
|
||||||
|
private var authenticationMonitor: Task<Void, Never>?
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
restoreTask?.cancel()
|
||||||
|
authenticationMonitor?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
func makeRootController() -> UIViewController {
|
func makeRootController() -> UIViewController {
|
||||||
let shell = mobileShellFixture(state: .loading)
|
let shell = mobileShellFixture(state: .loading)
|
||||||
navigationControllers = shell.pages.map { page in
|
navigationControllers = shell.pages.map { page in
|
||||||
let root: UIViewController = page.tab == .passwords
|
let root: UIViewController = switch page.tab {
|
||||||
? PasswordDirectoryViewController(shellPage: page)
|
case .passwords:
|
||||||
: ShellViewController(page: page)
|
PasswordDirectoryViewController(shellPage: page, authentication: authentication)
|
||||||
|
case .preferences:
|
||||||
|
PreferencesViewController(page: page, authentication: authentication)
|
||||||
|
default:
|
||||||
|
ShellViewController(page: page)
|
||||||
|
}
|
||||||
let navigation = UINavigationController(rootViewController: root)
|
let navigation = UINavigationController(rootViewController: root)
|
||||||
navigation.navigationBar.prefersLargeTitles = true
|
navigation.navigationBar.prefersLargeTitles = true
|
||||||
navigation.tabBarItem = UITabBarItem(
|
navigation.tabBarItem = UITabBarItem(
|
||||||
@@ -54,6 +69,7 @@ private final class AppContext: NSObject, UITabBarControllerDelegate {
|
|||||||
tabs.viewControllers = navigationControllers
|
tabs.viewControllers = navigationControllers
|
||||||
tabs.delegate = self
|
tabs.delegate = self
|
||||||
restoreSelectedTab()
|
restoreSelectedTab()
|
||||||
|
monitorAuthentication()
|
||||||
return tabs
|
return tabs
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +106,29 @@ private final class AppContext: NSObject, UITabBarControllerDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func monitorAuthentication() {
|
||||||
|
authenticationMonitor?.cancel()
|
||||||
|
guard let authentication else { return }
|
||||||
|
authenticationMonitor = Task {
|
||||||
|
var wasUnlocked = (try? authentication.state().unlocked) ?? false
|
||||||
|
while !Task.isCancelled {
|
||||||
|
do {
|
||||||
|
try await Task.sleep(for: .seconds(1))
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let isUnlocked = (try? authentication.state().unlocked) ?? false
|
||||||
|
if isUnlocked != wasUnlocked {
|
||||||
|
wasUnlocked = isUnlocked
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: .ironStorageAuthenticationDidChange,
|
||||||
|
object: authentication
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -614,17 +653,245 @@ private final class ShellViewController: UITableViewController, MobileTabRoot {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class PreferencesViewController: UITableViewController, MobileTabRoot {
|
||||||
|
fileprivate let shellTab = MobileTab.preferences
|
||||||
|
private var page: MobilePage
|
||||||
|
private let authentication: MobileAuthentication?
|
||||||
|
private var state: MobileAuthenticationState?
|
||||||
|
private var preferenceTask: Task<Void, Never>?
|
||||||
|
private var loadTask: Task<Void, Never>?
|
||||||
|
private var loadGeneration = 0
|
||||||
|
|
||||||
|
init(page: MobilePage, authentication: MobileAuthentication?) {
|
||||||
|
self.page = page
|
||||||
|
self.authentication = authentication
|
||||||
|
super.init(style: .insetGrouped)
|
||||||
|
title = page.title
|
||||||
|
navigationItem.largeTitleDisplayMode = .always
|
||||||
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||||
|
title: "Update Token",
|
||||||
|
style: .plain,
|
||||||
|
target: self,
|
||||||
|
action: #selector(tokenUpdateRequested)
|
||||||
|
)
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(authenticationDidChange),
|
||||||
|
name: .ironStorageAuthenticationDidChange,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) is not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
preferenceTask?.cancel()
|
||||||
|
loadTask?.cancel()
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
reloadShell()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func numberOfSections(in tableView: UITableView) -> Int {
|
||||||
|
page.state == .ready ? 2 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
numberOfRowsInSection section: Int
|
||||||
|
) -> Int {
|
||||||
|
section == 0 ? 1 : 2
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
titleForHeaderInSection section: Int
|
||||||
|
) -> String? {
|
||||||
|
section == 0 ? "Secure Unlock" : "Authentication Session"
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
titleForFooterInSection section: Int
|
||||||
|
) -> String? {
|
||||||
|
if section == 0 {
|
||||||
|
return "When enabled, the GPG passphrase is device-only, requires a device passcode, and is invalidated when enrolled biometrics change."
|
||||||
|
}
|
||||||
|
return "Manual lock and inactivity expiry immediately revoke the shared Rust authentication lease."
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(
|
||||||
|
_ tableView: UITableView,
|
||||||
|
cellForRowAt indexPath: IndexPath
|
||||||
|
) -> UITableViewCell {
|
||||||
|
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
|
||||||
|
var content = cell.defaultContentConfiguration()
|
||||||
|
if indexPath.section == 0 {
|
||||||
|
content.image = UIImage(systemName: "faceid")
|
||||||
|
content.text = "Biometric Unlock"
|
||||||
|
content.secondaryText = state?.biometricUnlockEnabled == true
|
||||||
|
? "Protected passphrase enrolled"
|
||||||
|
: "Manual passphrase required"
|
||||||
|
let toggle = UISwitch()
|
||||||
|
toggle.isOn = state?.biometricUnlockEnabled == true
|
||||||
|
toggle.isEnabled = authentication != nil
|
||||||
|
toggle.addTarget(self, action: #selector(biometricToggleChanged(_:)), for: .valueChanged)
|
||||||
|
toggle.accessibilityLabel = "Biometric Unlock"
|
||||||
|
cell.accessoryView = toggle
|
||||||
|
cell.selectionStyle = .none
|
||||||
|
} else if indexPath.row == 0 {
|
||||||
|
let unlocked = state?.unlocked == true
|
||||||
|
content.image = UIImage(systemName: unlocked ? "lock.open.fill" : "lock.fill")
|
||||||
|
content.text = unlocked ? "Unlocked" : "Locked"
|
||||||
|
content.secondaryText = unlocked
|
||||||
|
? "Locks in \(state?.remainingSeconds ?? 0) seconds without activity"
|
||||||
|
: "Protected content is masked"
|
||||||
|
cell.selectionStyle = .none
|
||||||
|
} else {
|
||||||
|
content.image = UIImage(systemName: "lock.fill")
|
||||||
|
content.text = "Lock Now"
|
||||||
|
content.textProperties.color = .systemRed
|
||||||
|
content.secondaryText = "Revoke all active authentication handles"
|
||||||
|
cell.accessoryType = .disclosureIndicator
|
||||||
|
cell.isUserInteractionEnabled = state?.unlocked == true
|
||||||
|
cell.contentView.alpha = state?.unlocked == true ? 1 : 0.45
|
||||||
|
}
|
||||||
|
content.secondaryTextProperties.numberOfLines = 0
|
||||||
|
cell.contentConfiguration = content
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
|
guard indexPath.section == 1, indexPath.row == 1, let authentication else { return }
|
||||||
|
do {
|
||||||
|
try authentication.manualLock()
|
||||||
|
refreshState()
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: .ironStorageAuthenticationDidChange,
|
||||||
|
object: authentication
|
||||||
|
)
|
||||||
|
UIAccessibility.post(notification: .announcement, argument: "IronStorage locked")
|
||||||
|
} catch let error as MobileAuthenticationFfiError {
|
||||||
|
presentAuthenticationFailure(AuthenticationFailure(error))
|
||||||
|
} catch {
|
||||||
|
presentAuthenticationFailure(.unexpected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
apply(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reloadShell() {
|
||||||
|
loadGeneration += 1
|
||||||
|
let generation = loadGeneration
|
||||||
|
loadTask?.cancel()
|
||||||
|
loadTask = Task { [weak self] in
|
||||||
|
let shell = await Task.detached(priority: .userInitiated) { mobileShell() }.value
|
||||||
|
guard
|
||||||
|
!Task.isCancelled,
|
||||||
|
let self,
|
||||||
|
generation == loadGeneration,
|
||||||
|
let page = shell.pages.first(where: { $0.tab == .preferences })
|
||||||
|
else { return }
|
||||||
|
apply(page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func apply(_ page: MobilePage) {
|
||||||
|
self.page = page
|
||||||
|
title = page.title
|
||||||
|
guard page.state == .ready else {
|
||||||
|
var configuration = UIContentUnavailableConfiguration.empty()
|
||||||
|
configuration.image = UIImage(systemName: "gearshape")
|
||||||
|
configuration.text = page.stateTitle
|
||||||
|
configuration.secondaryText = page.stateDetail
|
||||||
|
contentUnavailableConfiguration = configuration
|
||||||
|
tableView.reloadData()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
contentUnavailableConfiguration = nil
|
||||||
|
refreshState()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func tokenUpdateRequested() {
|
||||||
|
navigationController?.pushViewController(TokenUpdateViewController(), animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func authenticationDidChange() {
|
||||||
|
refreshState()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func biometricToggleChanged(_ sender: UISwitch) {
|
||||||
|
guard let authentication else {
|
||||||
|
sender.setOn(false, animated: true)
|
||||||
|
presentAuthenticationFailure(.unavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sender.isEnabled = false
|
||||||
|
let enabled = sender.isOn
|
||||||
|
preferenceTask?.cancel()
|
||||||
|
preferenceTask = Task { [weak self, weak sender] in
|
||||||
|
let result = await Task.detached(priority: .userInitiated) {
|
||||||
|
do {
|
||||||
|
return Result<MobileAuthenticationState, AuthenticationFailure>.success(
|
||||||
|
try authentication.setBiometricUnlock(enabled: enabled)
|
||||||
|
)
|
||||||
|
} catch let error as MobileAuthenticationFfiError {
|
||||||
|
return .failure(AuthenticationFailure(error))
|
||||||
|
} catch {
|
||||||
|
return .failure(.unexpected)
|
||||||
|
}
|
||||||
|
}.value
|
||||||
|
guard !Task.isCancelled, let self else { return }
|
||||||
|
sender?.isEnabled = true
|
||||||
|
switch result {
|
||||||
|
case let .success(state):
|
||||||
|
self.state = state
|
||||||
|
tableView.reloadData()
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: .ironStorageAuthenticationDidChange,
|
||||||
|
object: authentication
|
||||||
|
)
|
||||||
|
case let .failure(failure):
|
||||||
|
sender?.setOn(!enabled, animated: true)
|
||||||
|
presentAuthenticationFailure(failure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshState() {
|
||||||
|
state = try? authentication?.state()
|
||||||
|
tableView.reloadData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private final class PasswordDirectoryViewController: UITableViewController, MobileTabRoot {
|
private final class PasswordDirectoryViewController: UITableViewController, MobileTabRoot {
|
||||||
fileprivate let shellTab = MobileTab.passwords
|
fileprivate let shellTab = MobileTab.passwords
|
||||||
private let path: String?
|
private let path: String?
|
||||||
|
private let authentication: MobileAuthentication?
|
||||||
private var shellPage: MobilePage?
|
private var shellPage: MobilePage?
|
||||||
private var directoryPage: MobilePasswordPage?
|
private var directoryPage: MobilePasswordPage?
|
||||||
private var loadTask: Task<Void, Never>?
|
private var loadTask: Task<Void, Never>?
|
||||||
private var loadGeneration = 0
|
private var loadGeneration = 0
|
||||||
|
|
||||||
init(shellPage: MobilePage, path: String? = nil) {
|
init(
|
||||||
|
shellPage: MobilePage,
|
||||||
|
authentication: MobileAuthentication?,
|
||||||
|
path: String? = nil
|
||||||
|
) {
|
||||||
self.shellPage = shellPage
|
self.shellPage = shellPage
|
||||||
|
self.authentication = authentication
|
||||||
self.path = path
|
self.path = path
|
||||||
super.init(style: .insetGrouped)
|
super.init(style: .insetGrouped)
|
||||||
title = shellPage.title
|
title = shellPage.title
|
||||||
@@ -639,8 +906,9 @@ private final class PasswordDirectoryViewController: UITableViewController, Mobi
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private init(path: String, title: String) {
|
private init(path: String, title: String, authentication: MobileAuthentication?) {
|
||||||
self.path = path
|
self.path = path
|
||||||
|
self.authentication = authentication
|
||||||
shellPage = nil
|
shellPage = nil
|
||||||
super.init(style: .insetGrouped)
|
super.init(style: .insetGrouped)
|
||||||
self.title = title
|
self.title = title
|
||||||
@@ -719,9 +987,13 @@ private final class PasswordDirectoryViewController: UITableViewController, Mobi
|
|||||||
guard let row = directoryPage?.rows[indexPath.row] else { return }
|
guard let row = directoryPage?.rows[indexPath.row] else { return }
|
||||||
let controller: UIViewController = switch row.kind {
|
let controller: UIViewController = switch row.kind {
|
||||||
case .directory:
|
case .directory:
|
||||||
PasswordDirectoryViewController(path: row.path, title: row.title)
|
PasswordDirectoryViewController(
|
||||||
|
path: row.path,
|
||||||
|
title: row.title,
|
||||||
|
authentication: authentication
|
||||||
|
)
|
||||||
case .entry:
|
case .entry:
|
||||||
LockedPasswordViewController(entry: row)
|
LockedPasswordViewController(entry: row, authentication: authentication)
|
||||||
}
|
}
|
||||||
navigationController?.pushViewController(controller, animated: true)
|
navigationController?.pushViewController(controller, animated: true)
|
||||||
}
|
}
|
||||||
@@ -872,9 +1144,17 @@ private struct PasswordFailure: Error, Sendable {
|
|||||||
@MainActor
|
@MainActor
|
||||||
private final class LockedPasswordViewController: UIViewController {
|
private final class LockedPasswordViewController: UIViewController {
|
||||||
private let entry: MobilePasswordRow
|
private let entry: MobilePasswordRow
|
||||||
|
private let authentication: MobileAuthentication?
|
||||||
|
private let imageView = UIImageView()
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let detailLabel = UILabel()
|
||||||
|
private let unlockButton = UIButton(type: .system)
|
||||||
|
private var unlockTask: Task<Void, Never>?
|
||||||
|
private var state: MobileAuthenticationState?
|
||||||
|
|
||||||
init(entry: MobilePasswordRow) {
|
init(entry: MobilePasswordRow, authentication: MobileAuthentication?) {
|
||||||
self.entry = entry
|
self.entry = entry
|
||||||
|
self.authentication = authentication
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
title = entry.title
|
title = entry.title
|
||||||
navigationItem.largeTitleDisplayMode = .never
|
navigationItem.largeTitleDisplayMode = .never
|
||||||
@@ -888,12 +1168,230 @@ private final class LockedPasswordViewController: UIViewController {
|
|||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
view.backgroundColor = .systemGroupedBackground
|
view.backgroundColor = .systemGroupedBackground
|
||||||
var configuration = UIContentUnavailableConfiguration.empty()
|
imageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 44)
|
||||||
configuration.image = UIImage(systemName: "lock.fill")
|
imageView.tintColor = .secondaryLabel
|
||||||
configuration.text = "Locked Password"
|
titleLabel.font = .preferredFont(forTextStyle: .title2)
|
||||||
configuration.secondaryText = "Authenticate to reveal this password entry."
|
titleLabel.adjustsFontForContentSizeCategory = true
|
||||||
contentUnavailableConfiguration = configuration
|
titleLabel.textAlignment = .center
|
||||||
|
detailLabel.font = .preferredFont(forTextStyle: .body)
|
||||||
|
detailLabel.adjustsFontForContentSizeCategory = true
|
||||||
|
detailLabel.textAlignment = .center
|
||||||
|
detailLabel.textColor = .secondaryLabel
|
||||||
|
detailLabel.numberOfLines = 0
|
||||||
|
unlockButton.configuration = .filled()
|
||||||
|
unlockButton.configuration?.cornerStyle = .capsule
|
||||||
|
unlockButton.addTarget(self, action: #selector(unlockRequested), for: .touchUpInside)
|
||||||
|
let stack = UIStackView(arrangedSubviews: [imageView, titleLabel, detailLabel, unlockButton])
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.alignment = .center
|
||||||
|
stack.spacing = 12
|
||||||
|
stack.setCustomSpacing(24, after: detailLabel)
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(stack)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
stack.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor),
|
||||||
|
stack.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor),
|
||||||
|
stack.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor),
|
||||||
|
detailLabel.widthAnchor.constraint(lessThanOrEqualToConstant: 360),
|
||||||
|
unlockButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 140),
|
||||||
|
])
|
||||||
view.accessibilityIdentifier = entry.id
|
view.accessibilityIdentifier = entry.id
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(authenticationDidChange),
|
||||||
|
name: .ironStorageAuthenticationDidChange,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
unlockTask?.cancel()
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
refreshState()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func authenticationDidChange() {
|
||||||
|
refreshState()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func unlockRequested() {
|
||||||
|
if state?.unlocked == true, let authentication {
|
||||||
|
do {
|
||||||
|
try authentication.touchUserActivity()
|
||||||
|
refreshState()
|
||||||
|
UIAccessibility.post(notification: .announcement, argument: "Unlock extended")
|
||||||
|
} catch let error as MobileAuthenticationFfiError {
|
||||||
|
presentAuthenticationFailure(AuthenticationFailure(error))
|
||||||
|
} catch {
|
||||||
|
presentAuthenticationFailure(.unexpected)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
unlock(passphrase: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func lockRequested() {
|
||||||
|
guard let authentication else { return }
|
||||||
|
do {
|
||||||
|
try authentication.manualLock()
|
||||||
|
state = try authentication.state()
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: .ironStorageAuthenticationDidChange,
|
||||||
|
object: authentication
|
||||||
|
)
|
||||||
|
render()
|
||||||
|
} catch let error as MobileAuthenticationFfiError {
|
||||||
|
presentAuthenticationFailure(AuthenticationFailure(error))
|
||||||
|
} catch {
|
||||||
|
presentAuthenticationFailure(.unexpected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshState() {
|
||||||
|
state = try? authentication?.state()
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func unlock(passphrase: String?) {
|
||||||
|
guard let authentication else {
|
||||||
|
presentAuthenticationFailure(.unavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
unlockTask?.cancel()
|
||||||
|
unlockButton.isEnabled = false
|
||||||
|
unlockButton.configuration?.showsActivityIndicator = true
|
||||||
|
let path = entry.path
|
||||||
|
unlockTask = Task { [weak self] in
|
||||||
|
let result = await Task.detached(priority: .userInitiated) {
|
||||||
|
do {
|
||||||
|
return Result<MobileAuthenticationState, AuthenticationFailure>.success(
|
||||||
|
try authentication.unlockEntry(path: path, passphrase: passphrase)
|
||||||
|
)
|
||||||
|
} catch let error as MobileAuthenticationFfiError {
|
||||||
|
return .failure(AuthenticationFailure(error))
|
||||||
|
} catch {
|
||||||
|
return .failure(.unexpected)
|
||||||
|
}
|
||||||
|
}.value
|
||||||
|
guard !Task.isCancelled, let self else { return }
|
||||||
|
unlockButton.isEnabled = true
|
||||||
|
unlockButton.configuration?.showsActivityIndicator = false
|
||||||
|
switch result {
|
||||||
|
case let .success(state):
|
||||||
|
self.state = state
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: .ironStorageAuthenticationDidChange,
|
||||||
|
object: authentication
|
||||||
|
)
|
||||||
|
render()
|
||||||
|
UIAccessibility.post(notification: .announcement, argument: "Password unlocked")
|
||||||
|
case let .failure(failure):
|
||||||
|
if passphrase == nil,
|
||||||
|
failure.kind == .passphraseRequired || failure.kind == .biometryUnavailable {
|
||||||
|
promptForPassphrase(message: failure.detail)
|
||||||
|
} else if failure.kind != .cancelled {
|
||||||
|
presentAuthenticationFailure(failure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func promptForPassphrase(message: String) {
|
||||||
|
let alert = UIAlertController(
|
||||||
|
title: "GPG Key Passphrase",
|
||||||
|
message: message,
|
||||||
|
preferredStyle: .alert
|
||||||
|
)
|
||||||
|
alert.addTextField { field in
|
||||||
|
field.isSecureTextEntry = true
|
||||||
|
field.textContentType = .password
|
||||||
|
field.placeholder = "Passphrase"
|
||||||
|
field.returnKeyType = .go
|
||||||
|
}
|
||||||
|
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||||
|
alert.addAction(UIAlertAction(title: "Unlock", style: .default) { [weak self, weak alert] _ in
|
||||||
|
guard let field = alert?.textFields?.first, let value = field.text, !value.isEmpty else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
field.text = nil
|
||||||
|
self?.unlock(passphrase: value)
|
||||||
|
})
|
||||||
|
present(alert, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func render() {
|
||||||
|
let unlocked = state?.unlocked == true
|
||||||
|
imageView.image = UIImage(systemName: unlocked ? "lock.open.fill" : "lock.fill")
|
||||||
|
titleLabel.text = unlocked ? "Key Unlocked" : "Locked Password"
|
||||||
|
detailLabel.text = if unlocked {
|
||||||
|
"The GPG key is available for protected operations for up to \(state?.remainingSeconds ?? 0) seconds."
|
||||||
|
} else {
|
||||||
|
"Authenticate to unlock this password entry. Browsing remains available while locked."
|
||||||
|
}
|
||||||
|
unlockButton.configuration?.title = unlocked ? "Extend Unlock" : "Unlock"
|
||||||
|
navigationItem.rightBarButtonItem = unlocked
|
||||||
|
? UIBarButtonItem(
|
||||||
|
image: UIImage(systemName: "lock.fill"),
|
||||||
|
style: .plain,
|
||||||
|
target: self,
|
||||||
|
action: #selector(lockRequested)
|
||||||
|
)
|
||||||
|
: nil
|
||||||
|
navigationItem.rightBarButtonItem?.accessibilityLabel = "Lock IronStorage"
|
||||||
|
unlockButton.accessibilityHint = unlocked
|
||||||
|
? "Extends access after authentication."
|
||||||
|
: "Requests biometric authentication or the GPG key passphrase."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct AuthenticationFailure: Error, Sendable {
|
||||||
|
let kind: MobileAuthenticationErrorKind
|
||||||
|
let title: String
|
||||||
|
let detail: String
|
||||||
|
|
||||||
|
init(_ error: MobileAuthenticationFfiError) {
|
||||||
|
switch error {
|
||||||
|
case let .Failed(kind, title, detail):
|
||||||
|
self.kind = kind
|
||||||
|
self.title = title
|
||||||
|
self.detail = detail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static let unexpected = AuthenticationFailure(
|
||||||
|
kind: .secureStorage,
|
||||||
|
title: "Unlock Failed",
|
||||||
|
detail: "IronStorage could not complete authentication."
|
||||||
|
)
|
||||||
|
|
||||||
|
static let unavailable = AuthenticationFailure(
|
||||||
|
kind: .configuration,
|
||||||
|
title: "Authentication Is Unavailable",
|
||||||
|
detail: "Finish password-store setup before unlocking entries."
|
||||||
|
)
|
||||||
|
|
||||||
|
private init(kind: MobileAuthenticationErrorKind, title: String, detail: String) {
|
||||||
|
self.kind = kind
|
||||||
|
self.title = title
|
||||||
|
self.detail = detail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private extension UIViewController {
|
||||||
|
func presentAuthenticationFailure(_ failure: AuthenticationFailure) {
|
||||||
|
let alert = UIAlertController(
|
||||||
|
title: failure.title,
|
||||||
|
message: failure.detail,
|
||||||
|
preferredStyle: .alert
|
||||||
|
)
|
||||||
|
alert.addAction(UIAlertAction(title: "OK", style: .default))
|
||||||
|
present(alert, animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ targets:
|
|||||||
properties:
|
properties:
|
||||||
CFBundleDisplayName: IronStorage
|
CFBundleDisplayName: IronStorage
|
||||||
ITSAppUsesNonExemptEncryption: false
|
ITSAppUsesNonExemptEncryption: false
|
||||||
|
NSFaceIDUsageDescription: Unlock your GPG key for protected password operations.
|
||||||
UILaunchScreen: {}
|
UILaunchScreen: {}
|
||||||
UISupportedInterfaceOrientations:
|
UISupportedInterfaceOrientations:
|
||||||
- UIInterfaceOrientationPortrait
|
- UIInterfaceOrientationPortrait
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ use std::{error::Error, fmt, sync::Arc};
|
|||||||
use ironstorage::{
|
use ironstorage::{
|
||||||
config::ConfigError,
|
config::ConfigError,
|
||||||
mobile::{self, MobileShellState as StorageShellState, MobileTab as StorageTab},
|
mobile::{self, MobileShellState as StorageShellState, MobileTab as StorageTab},
|
||||||
|
mobile_authentication::{
|
||||||
|
MobileAuthenticationError as StorageAuthenticationError,
|
||||||
|
MobileAuthenticationErrorKind as StorageAuthenticationErrorKind,
|
||||||
|
MobileAuthenticationState as StorageAuthenticationState,
|
||||||
|
},
|
||||||
mobile_home::{
|
mobile_home::{
|
||||||
self, MobileHomeChangeKind as StorageHomeChangeKind,
|
self, MobileHomeChangeKind as StorageHomeChangeKind,
|
||||||
MobileHomeChangeStatus as StorageHomeChangeStatus, MobileHomeError as StorageHomeError,
|
MobileHomeChangeStatus as StorageHomeChangeStatus, MobileHomeError as StorageHomeError,
|
||||||
@@ -491,6 +496,135 @@ impl From<StoragePasswordError> for MobilePasswordFfiError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||||
|
pub enum MobileAuthenticationErrorKind {
|
||||||
|
PassphraseRequired,
|
||||||
|
InvalidPassphrase,
|
||||||
|
Cancelled,
|
||||||
|
BiometryUnavailable,
|
||||||
|
Configuration,
|
||||||
|
KeyMaterial,
|
||||||
|
Entry,
|
||||||
|
SecureStorage,
|
||||||
|
Expired,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<StorageAuthenticationErrorKind> for MobileAuthenticationErrorKind {
|
||||||
|
fn from(kind: StorageAuthenticationErrorKind) -> Self {
|
||||||
|
match kind {
|
||||||
|
StorageAuthenticationErrorKind::PassphraseRequired => Self::PassphraseRequired,
|
||||||
|
StorageAuthenticationErrorKind::InvalidPassphrase => Self::InvalidPassphrase,
|
||||||
|
StorageAuthenticationErrorKind::Cancelled => Self::Cancelled,
|
||||||
|
StorageAuthenticationErrorKind::BiometryUnavailable => Self::BiometryUnavailable,
|
||||||
|
StorageAuthenticationErrorKind::Configuration => Self::Configuration,
|
||||||
|
StorageAuthenticationErrorKind::KeyMaterial => Self::KeyMaterial,
|
||||||
|
StorageAuthenticationErrorKind::Entry => Self::Entry,
|
||||||
|
StorageAuthenticationErrorKind::SecureStorage => Self::SecureStorage,
|
||||||
|
StorageAuthenticationErrorKind::Expired => Self::Expired,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct MobileAuthenticationState {
|
||||||
|
pub unlocked: bool,
|
||||||
|
pub biometric_unlock_enabled: bool,
|
||||||
|
pub remaining_seconds: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<StorageAuthenticationState> for MobileAuthenticationState {
|
||||||
|
fn from(state: StorageAuthenticationState) -> Self {
|
||||||
|
Self {
|
||||||
|
unlocked: state.unlocked(),
|
||||||
|
biometric_unlock_enabled: state.biometric_unlock_enabled(),
|
||||||
|
remaining_seconds: state.remaining_seconds(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, uniffi::Error)]
|
||||||
|
pub enum MobileAuthenticationFfiError {
|
||||||
|
Failed {
|
||||||
|
kind: MobileAuthenticationErrorKind,
|
||||||
|
title: String,
|
||||||
|
detail: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for MobileAuthenticationFfiError {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Failed { title, detail, .. } => write!(formatter, "{title}: {detail}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error for MobileAuthenticationFfiError {}
|
||||||
|
|
||||||
|
impl From<StorageAuthenticationError> for MobileAuthenticationFfiError {
|
||||||
|
fn from(error: StorageAuthenticationError) -> Self {
|
||||||
|
Self::Failed {
|
||||||
|
kind: error.kind().into(),
|
||||||
|
title: error.title().to_owned(),
|
||||||
|
detail: error.detail().to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(uniffi::Object)]
|
||||||
|
pub struct MobileAuthentication {
|
||||||
|
authentication: ironstorage::mobile_authentication::MobileAuthentication,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[uniffi::export]
|
||||||
|
impl MobileAuthentication {
|
||||||
|
pub fn state(&self) -> Result<MobileAuthenticationState, MobileAuthenticationFfiError> {
|
||||||
|
self.authentication
|
||||||
|
.state()
|
||||||
|
.map(Into::into)
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unlock_entry(
|
||||||
|
&self,
|
||||||
|
path: String,
|
||||||
|
passphrase: Option<String>,
|
||||||
|
) -> Result<MobileAuthenticationState, MobileAuthenticationFfiError> {
|
||||||
|
self.authentication
|
||||||
|
.unlock_entry(
|
||||||
|
&path,
|
||||||
|
passphrase
|
||||||
|
.map(|value| ironstorage::repository::SecretBytes::new(value.into_bytes())),
|
||||||
|
)
|
||||||
|
.map(Into::into)
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_biometric_unlock(
|
||||||
|
&self,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<MobileAuthenticationState, MobileAuthenticationFfiError> {
|
||||||
|
self.authentication
|
||||||
|
.set_biometric_unlock(enabled)
|
||||||
|
.map(Into::into)
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn touch_user_activity(&self) -> Result<(), MobileAuthenticationFfiError> {
|
||||||
|
self.authentication
|
||||||
|
.touch_user_activity()
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn manual_lock(&self) -> Result<(), MobileAuthenticationFfiError> {
|
||||||
|
self.authentication.manual_lock().map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cancel(&self) -> Result<(), MobileAuthenticationFfiError> {
|
||||||
|
self.authentication.cancel().map_err(Into::into)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||||
pub enum MobileOnboardingPhase {
|
pub enum MobileOnboardingPhase {
|
||||||
Validating,
|
Validating,
|
||||||
@@ -711,6 +845,13 @@ pub fn mobile_password_page(
|
|||||||
.map_err(Into::into)
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[uniffi::export]
|
||||||
|
pub fn mobile_authentication() -> Result<Arc<MobileAuthentication>, MobileAuthenticationFfiError> {
|
||||||
|
Ok(Arc::new(MobileAuthentication {
|
||||||
|
authentication: ironstorage::mobile_authentication::MobileAuthentication::load()?,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
#[uniffi::export]
|
#[uniffi::export]
|
||||||
pub fn mobile_onboarding_operation(
|
pub fn mobile_onboarding_operation(
|
||||||
server_url: String,
|
server_url: String,
|
||||||
|
|||||||
@@ -227,36 +227,7 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> AuthenticationSession<B, C>
|
|||||||
}
|
}
|
||||||
let _operation = self.shared.operation()?;
|
let _operation = self.shared.operation()?;
|
||||||
self.shared.expire_if_needed()?;
|
self.shared.expire_if_needed()?;
|
||||||
let now = self.shared.clock.now();
|
let generation = self.shared.begin_lease()?;
|
||||||
let deadline = now
|
|
||||||
.checked_add(self.shared.timeout.duration())
|
|
||||||
.ok_or(AuthenticationError::ClockOverflow)?;
|
|
||||||
|
|
||||||
let (generation, was_active) = {
|
|
||||||
let mut state = self.shared.state()?;
|
|
||||||
match state.active.as_mut() {
|
|
||||||
Some(active) => {
|
|
||||||
active.deadline = deadline;
|
|
||||||
(active.generation, true)
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
state.next_generation = state.next_generation.wrapping_add(1);
|
|
||||||
let generation = state.next_generation;
|
|
||||||
state.active = Some(ActiveLease {
|
|
||||||
generation,
|
|
||||||
deadline,
|
|
||||||
unlock_material: BTreeMap::new(),
|
|
||||||
});
|
|
||||||
(generation, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if !was_active && let Err(error) = self.shared.store.unlock() {
|
|
||||||
self.shared
|
|
||||||
.revoke_local(generation, RevocationReason::AuthenticationFailed)?;
|
|
||||||
return Err(error.into());
|
|
||||||
}
|
|
||||||
|
|
||||||
if key.requires_passphrase()
|
if key.requires_passphrase()
|
||||||
&& let Err(error) = self.shared.cache_key_passphrase(generation, key)
|
&& let Err(error) = self.shared.cache_key_passphrase(generation, key)
|
||||||
@@ -273,6 +244,40 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> AuthenticationSession<B, C>
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Establish a lease from a manually entered passphrase without persisting it.
|
||||||
|
pub fn authenticate_with_passphrase(
|
||||||
|
&self,
|
||||||
|
key: &KeyInfo,
|
||||||
|
passphrase: SecretBytes,
|
||||||
|
) -> Result<AuthenticationHandle<B, C>, AuthenticationError> {
|
||||||
|
if !key.has_secret() || !key.requires_passphrase() || passphrase.expose().is_empty() {
|
||||||
|
return Err(AuthenticationError::Locked);
|
||||||
|
}
|
||||||
|
let _operation = self.shared.operation()?;
|
||||||
|
self.shared.expire_if_needed()?;
|
||||||
|
let generation = self.shared.begin_lease()?;
|
||||||
|
let fingerprint = key.fingerprint().as_str().to_owned();
|
||||||
|
self.shared.with_active(generation, |active| {
|
||||||
|
active.unlock_material.insert(fingerprint, passphrase);
|
||||||
|
})?;
|
||||||
|
Ok(AuthenticationHandle {
|
||||||
|
shared: Arc::clone(&self.shared),
|
||||||
|
generation,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove this key's OS-protected passphrase. A missing item is already disabled.
|
||||||
|
pub fn delete_key_passphrase(&self, key: &KeyInfo) -> Result<(), AuthenticationError> {
|
||||||
|
let _operation = self.shared.operation()?;
|
||||||
|
self.shared.expire_if_needed()?;
|
||||||
|
self.shared.store.unlock()?;
|
||||||
|
let reference = SecretReference::openpgp_passphrase(key.fingerprint().as_str())?;
|
||||||
|
match self.shared.store.delete_openpgp_passphrase(&reference) {
|
||||||
|
Ok(()) | Err(SecretStoreError::Missing) => Ok(()),
|
||||||
|
Err(error) => Err(error.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Remaining active time. Calling this from a timer never extends the lease.
|
/// Remaining active time. Calling this from a timer never extends the lease.
|
||||||
pub fn remaining_time(&self) -> Result<Option<Duration>, AuthenticationError> {
|
pub fn remaining_time(&self) -> Result<Option<Duration>, AuthenticationError> {
|
||||||
let _operation = self.shared.operation()?;
|
let _operation = self.shared.operation()?;
|
||||||
@@ -360,6 +365,25 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> AuthenticationHandle<B, C> {
|
|||||||
active.deadline.saturating_sub(now)
|
active.deadline.saturating_sub(now)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persist the passphrase already verified by a successful crypto operation.
|
||||||
|
pub fn persist_passphrase(&self, key: &KeyInfo) -> Result<(), AuthenticationError> {
|
||||||
|
let _operation = self.shared.operation()?;
|
||||||
|
self.shared.expire_if_needed()?;
|
||||||
|
let fingerprint = key.fingerprint().as_str();
|
||||||
|
let value = self.shared.with_active(self.generation, |active| {
|
||||||
|
active
|
||||||
|
.unlock_material
|
||||||
|
.get(fingerprint)
|
||||||
|
.map(|value| SecretBytes::new(value.expose().to_vec()))
|
||||||
|
})?;
|
||||||
|
let value = value.ok_or(AuthenticationError::Locked)?;
|
||||||
|
let reference = SecretReference::openpgp_passphrase(fingerprint)?;
|
||||||
|
self.shared
|
||||||
|
.store
|
||||||
|
.store_openpgp_passphrase(&reference, value)
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<B: SecretStoreBackend, C: AuthenticationClock> SecretProvider for AuthenticationHandle<B, C> {
|
impl<B: SecretStoreBackend, C: AuthenticationClock> SecretProvider for AuthenticationHandle<B, C> {
|
||||||
@@ -408,6 +432,37 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> Shared<B, C> {
|
|||||||
.map_err(|_| AuthenticationError::SecretStore(SecretStoreError::Unavailable))
|
.map_err(|_| AuthenticationError::SecretStore(SecretStoreError::Unavailable))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn begin_lease(&self) -> Result<u64, AuthenticationError> {
|
||||||
|
let now = self.clock.now();
|
||||||
|
let deadline = now
|
||||||
|
.checked_add(self.timeout.duration())
|
||||||
|
.ok_or(AuthenticationError::ClockOverflow)?;
|
||||||
|
let (generation, was_active) = {
|
||||||
|
let mut state = self.state()?;
|
||||||
|
match state.active.as_mut() {
|
||||||
|
Some(active) => {
|
||||||
|
active.deadline = deadline;
|
||||||
|
(active.generation, true)
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
state.next_generation = state.next_generation.wrapping_add(1);
|
||||||
|
let generation = state.next_generation;
|
||||||
|
state.active = Some(ActiveLease {
|
||||||
|
generation,
|
||||||
|
deadline,
|
||||||
|
unlock_material: BTreeMap::new(),
|
||||||
|
});
|
||||||
|
(generation, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !was_active && let Err(error) = self.store.unlock() {
|
||||||
|
self.revoke_local(generation, RevocationReason::AuthenticationFailed)?;
|
||||||
|
return Err(error.into());
|
||||||
|
}
|
||||||
|
Ok(generation)
|
||||||
|
}
|
||||||
|
|
||||||
fn expire_if_needed(&self) -> Result<bool, AuthenticationError> {
|
fn expire_if_needed(&self) -> Result<bool, AuthenticationError> {
|
||||||
let now = self.clock.now();
|
let now = self.clock.now();
|
||||||
let expired = self
|
let expired = self
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ pub struct Config {
|
|||||||
editor: Option<EditorCommand>,
|
editor: Option<EditorCommand>,
|
||||||
clipboard_timeout: ClipboardTimeout,
|
clipboard_timeout: ClipboardTimeout,
|
||||||
authentication_timeout: AuthenticationTimeout,
|
authentication_timeout: AuthenticationTimeout,
|
||||||
|
biometric_unlock_enabled: bool,
|
||||||
mobile_tab: MobileTab,
|
mobile_tab: MobileTab,
|
||||||
mobile_home_refreshed_at: Option<i64>,
|
mobile_home_refreshed_at: Option<i64>,
|
||||||
git_remotes: Vec<GitRemote>,
|
git_remotes: Vec<GitRemote>,
|
||||||
@@ -122,6 +123,10 @@ impl Config {
|
|||||||
self.authentication_timeout
|
self.authentication_timeout
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn biometric_unlock_enabled(&self) -> bool {
|
||||||
|
self.biometric_unlock_enabled
|
||||||
|
}
|
||||||
|
|
||||||
pub fn mobile_tab(&self) -> MobileTab {
|
pub fn mobile_tab(&self) -> MobileTab {
|
||||||
self.mobile_tab
|
self.mobile_tab
|
||||||
}
|
}
|
||||||
@@ -203,6 +208,31 @@ impl Config {
|
|||||||
validate_config(self.source.clone(), document, raw)?.persist()
|
validate_config(self.source.clone(), document, raw)?.persist()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn update_biometric_unlock(&self, enabled: bool) -> Result<(), ConfigError> {
|
||||||
|
let mut document = self.document.clone();
|
||||||
|
let root = document
|
||||||
|
.as_table_mut()
|
||||||
|
.ok_or_else(|| ConfigError::Malformed {
|
||||||
|
path: self.source.clone(),
|
||||||
|
})?;
|
||||||
|
let security = root
|
||||||
|
.entry("security")
|
||||||
|
.or_insert_with(|| toml::Value::Table(toml::Table::new()))
|
||||||
|
.as_table_mut()
|
||||||
|
.ok_or(ConfigError::InvalidField { field: "security" })?;
|
||||||
|
security.insert(
|
||||||
|
"biometric_unlock_enabled".to_owned(),
|
||||||
|
toml::Value::Boolean(enabled),
|
||||||
|
);
|
||||||
|
let raw = document
|
||||||
|
.clone()
|
||||||
|
.try_into::<RawConfig>()
|
||||||
|
.map_err(|_| ConfigError::Malformed {
|
||||||
|
path: self.source.clone(),
|
||||||
|
})?;
|
||||||
|
validate_config(self.source.clone(), document, raw)?.persist()
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn create_mobile_clone(
|
pub(crate) fn create_mobile_clone(
|
||||||
source: PathBuf,
|
source: PathBuf,
|
||||||
vault: &Path,
|
vault: &Path,
|
||||||
@@ -835,6 +865,7 @@ struct RawConfig {
|
|||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
struct RawSecurity {
|
struct RawSecurity {
|
||||||
inactivity_timeout_seconds: Option<u64>,
|
inactivity_timeout_seconds: Option<u64>,
|
||||||
|
biometric_unlock_enabled: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Deserialize)]
|
#[derive(Default, Deserialize)]
|
||||||
@@ -916,6 +947,7 @@ fn validate_config(
|
|||||||
.map_err(|_| ConfigError::InvalidField {
|
.map_err(|_| ConfigError::InvalidField {
|
||||||
field: "security.inactivity_timeout_seconds",
|
field: "security.inactivity_timeout_seconds",
|
||||||
})?;
|
})?;
|
||||||
|
let biometric_unlock_enabled = raw.security.biometric_unlock_enabled.unwrap_or(false);
|
||||||
let mobile_tab = raw
|
let mobile_tab = raw
|
||||||
.ui
|
.ui
|
||||||
.selected_mobile_tab
|
.selected_mobile_tab
|
||||||
@@ -943,6 +975,7 @@ fn validate_config(
|
|||||||
editor,
|
editor,
|
||||||
clipboard_timeout,
|
clipboard_timeout,
|
||||||
authentication_timeout,
|
authentication_timeout,
|
||||||
|
biometric_unlock_enabled,
|
||||||
mobile_tab,
|
mobile_tab,
|
||||||
mobile_home_refreshed_at,
|
mobile_home_refreshed_at,
|
||||||
git_remotes,
|
git_remotes,
|
||||||
@@ -1113,7 +1146,11 @@ fn validate_known_fields(value: &toml::Value, source: &Path) -> Result<(), Confi
|
|||||||
let security = security.as_table().ok_or_else(|| ConfigError::Malformed {
|
let security = security.as_table().ok_or_else(|| ConfigError::Malformed {
|
||||||
path: source.to_owned(),
|
path: source.to_owned(),
|
||||||
})?;
|
})?;
|
||||||
validate_table(security, "security", &["inactivity_timeout_seconds"])?;
|
validate_table(
|
||||||
|
security,
|
||||||
|
"security",
|
||||||
|
&["inactivity_timeout_seconds", "biometric_unlock_enabled"],
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
if let Some(ui) = root.get("ui") {
|
if let Some(ui) = root.get("ui") {
|
||||||
let ui = ui.as_table().ok_or_else(|| ConfigError::Malformed {
|
let ui = ui.as_table().ok_or_else(|| ConfigError::Malformed {
|
||||||
|
|||||||
@@ -407,6 +407,27 @@ impl KeyStore {
|
|||||||
Ok(actual == expected)
|
Ok(actual == expected)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return the secret keys named by a pass entry's PKESK packets.
|
||||||
|
pub fn decrypting_keys(
|
||||||
|
&self,
|
||||||
|
ciphertext: &EncryptedEntry,
|
||||||
|
) -> Result<Vec<KeyInfo>, CryptoError> {
|
||||||
|
let message = Message::from_bytes(Cursor::new(ciphertext.as_bytes()))
|
||||||
|
.map_err(|_| CryptoError::CorruptMessage)?;
|
||||||
|
if !message.is_encrypted() {
|
||||||
|
return Err(CryptoError::CorruptMessage);
|
||||||
|
}
|
||||||
|
let keys = self
|
||||||
|
.decrypting_material(&message)
|
||||||
|
.into_iter()
|
||||||
|
.map(|(_, material)| key_info(material))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if keys.is_empty() {
|
||||||
|
return Err(CryptoError::MissingSecretKey);
|
||||||
|
}
|
||||||
|
Ok(keys)
|
||||||
|
}
|
||||||
|
|
||||||
/// Decrypt a pass entry with only the secret keys named by its PKESK packets.
|
/// Decrypt a pass entry with only the secret keys named by its PKESK packets.
|
||||||
pub fn decrypt(
|
pub fn decrypt(
|
||||||
&self,
|
&self,
|
||||||
@@ -418,16 +439,7 @@ impl KeyStore {
|
|||||||
if !message.is_encrypted() {
|
if !message.is_encrypted() {
|
||||||
return Err(CryptoError::CorruptMessage);
|
return Err(CryptoError::CorruptMessage);
|
||||||
}
|
}
|
||||||
let candidates = self
|
let candidates = self.decrypting_material(&message);
|
||||||
.keys
|
|
||||||
.iter()
|
|
||||||
.filter(|(_, material)| {
|
|
||||||
material
|
|
||||||
.secret
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|secret| message_matches_secret(&message, secret))
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
if candidates.is_empty() {
|
if candidates.is_empty() {
|
||||||
return Err(CryptoError::MissingSecretKey);
|
return Err(CryptoError::MissingSecretKey);
|
||||||
}
|
}
|
||||||
@@ -491,6 +503,21 @@ impl KeyStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn decrypting_material<'a>(
|
||||||
|
&'a self,
|
||||||
|
message: &Message<'_>,
|
||||||
|
) -> Vec<(&'a KeyFingerprint, &'a KeyMaterial)> {
|
||||||
|
self.keys
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, material)| {
|
||||||
|
material
|
||||||
|
.secret
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|secret| message_matches_secret(message, secret))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn sign(
|
pub fn sign(
|
||||||
&self,
|
&self,
|
||||||
data: &[u8],
|
data: &[u8],
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ pub mod generate;
|
|||||||
pub mod git;
|
pub mod git;
|
||||||
pub mod kdbx;
|
pub mod kdbx;
|
||||||
pub mod mobile;
|
pub mod mobile;
|
||||||
|
pub mod mobile_authentication;
|
||||||
pub mod mobile_home;
|
pub mod mobile_home;
|
||||||
pub mod mobile_onboarding;
|
pub mod mobile_onboarding;
|
||||||
pub mod mobile_passwords;
|
pub mod mobile_passwords;
|
||||||
|
|||||||
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 {
|
pub enum SecretProtection {
|
||||||
DeviceUnlocked,
|
DeviceUnlocked,
|
||||||
RequireUserPresence,
|
RequireUserPresence,
|
||||||
|
BiometryCurrentSet,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
@@ -197,6 +198,13 @@ impl SecretProtectionPolicy {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub const fn current_biometry_for_openpgp() -> Self {
|
||||||
|
Self::new(
|
||||||
|
SecretProtection::BiometryCurrentSet,
|
||||||
|
SecretProtection::DeviceUnlocked,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn for_reference(self, reference: &SecretReference) -> SecretProtection {
|
fn for_reference(self, reference: &SecretReference) -> SecretProtection {
|
||||||
match &reference.kind {
|
match &reference.kind {
|
||||||
SecretReferenceKind::OpenPgpPassphrase { .. } => self.openpgp,
|
SecretReferenceKind::OpenPgpPassphrase { .. } => self.openpgp,
|
||||||
@@ -474,6 +482,40 @@ impl<B: SecretStoreBackend> SecretStore<B> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persist a verified OpenPGP passphrase without first reading the old item.
|
||||||
|
/// This lets Apple replace an item invalidated by biometric enrollment changes.
|
||||||
|
pub(crate) fn store_openpgp_passphrase(
|
||||||
|
&self,
|
||||||
|
reference: &SecretReference,
|
||||||
|
value: SecretBytes,
|
||||||
|
) -> Result<(), SecretStoreError> {
|
||||||
|
if !matches!(
|
||||||
|
reference.kind,
|
||||||
|
SecretReferenceKind::OpenPgpPassphrase { .. }
|
||||||
|
) {
|
||||||
|
return Err(SecretStoreError::InvalidReference);
|
||||||
|
}
|
||||||
|
validate_secret(&value)?;
|
||||||
|
let locator = reference.locator();
|
||||||
|
let encoded = encode_record(reference, &value)?;
|
||||||
|
let mut state = self.unlocked_state()?;
|
||||||
|
match self.backend.replace(
|
||||||
|
&locator,
|
||||||
|
self.protections.for_reference(reference),
|
||||||
|
encoded.expose(),
|
||||||
|
) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(SecretStoreError::Missing) => self.backend.create(
|
||||||
|
&locator,
|
||||||
|
self.protections.for_reference(reference),
|
||||||
|
encoded.expose(),
|
||||||
|
)?,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
}
|
||||||
|
self.cache_insert(&mut state, locator, encoded);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Create or replace one HTTPS Git account/token record without exposing
|
/// Create or replace one HTTPS Git account/token record without exposing
|
||||||
/// the previously stored account or token to a frontend.
|
/// the previously stored account or token to a frontend.
|
||||||
pub fn store_https_git_credential(
|
pub fn store_https_git_credential(
|
||||||
@@ -525,6 +567,24 @@ impl<B: SecretStoreBackend> SecretStore<B> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn delete_openpgp_passphrase(
|
||||||
|
&self,
|
||||||
|
reference: &SecretReference,
|
||||||
|
) -> Result<(), SecretStoreError> {
|
||||||
|
if !matches!(
|
||||||
|
reference.kind,
|
||||||
|
SecretReferenceKind::OpenPgpPassphrase { .. }
|
||||||
|
) {
|
||||||
|
return Err(SecretStoreError::InvalidReference);
|
||||||
|
}
|
||||||
|
let locator = reference.locator();
|
||||||
|
let mut state = self.unlocked_state()?;
|
||||||
|
self.backend
|
||||||
|
.delete(&locator, self.protections.for_reference(reference))?;
|
||||||
|
state.cache.remove(&locator);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn retrieve_git_record(
|
fn retrieve_git_record(
|
||||||
&self,
|
&self,
|
||||||
server: &ServerId,
|
server: &ServerId,
|
||||||
|
|||||||
@@ -11,6 +11,15 @@ use std::sync::Arc;
|
|||||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||||
|
use security_framework::{
|
||||||
|
access_control::{ProtectionMode, SecAccessControl},
|
||||||
|
passwords::{
|
||||||
|
AccessControlOptions, PasswordOptions, delete_generic_password_options, generic_password,
|
||||||
|
set_generic_password_options,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
use keyring_core::{CredentialStore, Entry};
|
use keyring_core::{CredentialStore, Entry};
|
||||||
|
|
||||||
use super::{SecretLocator, SecretProtection, SecretStoreBackend, SecretStoreError};
|
use super::{SecretLocator, SecretProtection, SecretStoreBackend, SecretStoreError};
|
||||||
@@ -77,7 +86,7 @@ impl NativeSecretBackend {
|
|||||||
let (service, user) = locator.service_and_user();
|
let (service, user) = locator.service_and_user();
|
||||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||||
{
|
{
|
||||||
if protection == SecretProtection::RequireUserPresence {
|
if protection != SecretProtection::DeviceUnlocked {
|
||||||
return Err(SecretStoreError::UnsupportedProtection);
|
return Err(SecretStoreError::UnsupportedProtection);
|
||||||
}
|
}
|
||||||
self.store.build(service, &user, None).map_err(map_error)
|
self.store.build(service, &user, None).map_err(map_error)
|
||||||
@@ -101,6 +110,7 @@ impl NativeSecretBackend {
|
|||||||
.build(service, &user, modifiers.as_ref())
|
.build(service, &user, modifiers.as_ref())
|
||||||
.map_err(map_error)
|
.map_err(map_error)
|
||||||
}
|
}
|
||||||
|
SecretProtection::BiometryCurrentSet => Err(SecretStoreError::Unavailable),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(not(any(
|
#[cfg(not(any(
|
||||||
@@ -123,6 +133,10 @@ impl SecretStoreBackend for NativeSecretBackend {
|
|||||||
protection: SecretProtection,
|
protection: SecretProtection,
|
||||||
value: &[u8],
|
value: &[u8],
|
||||||
) -> Result<(), SecretStoreError> {
|
) -> Result<(), SecretStoreError> {
|
||||||
|
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||||
|
if protection == SecretProtection::BiometryCurrentSet {
|
||||||
|
return strict_set(locator, value);
|
||||||
|
}
|
||||||
let entry = self.entry(locator, protection)?;
|
let entry = self.entry(locator, protection)?;
|
||||||
match entry.get_secret() {
|
match entry.get_secret() {
|
||||||
Ok(existing) => {
|
Ok(existing) => {
|
||||||
@@ -139,6 +153,12 @@ impl SecretStoreBackend for NativeSecretBackend {
|
|||||||
locator: &SecretLocator,
|
locator: &SecretLocator,
|
||||||
protection: SecretProtection,
|
protection: SecretProtection,
|
||||||
) -> Result<SecretBytes, SecretStoreError> {
|
) -> Result<SecretBytes, SecretStoreError> {
|
||||||
|
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||||
|
if protection == SecretProtection::BiometryCurrentSet {
|
||||||
|
return generic_password(strict_query(locator))
|
||||||
|
.map(SecretBytes::new)
|
||||||
|
.map_err(map_security_error);
|
||||||
|
}
|
||||||
self.entry(locator, protection)?
|
self.entry(locator, protection)?
|
||||||
.get_secret()
|
.get_secret()
|
||||||
.map(SecretBytes::new)
|
.map(SecretBytes::new)
|
||||||
@@ -151,6 +171,11 @@ impl SecretStoreBackend for NativeSecretBackend {
|
|||||||
protection: SecretProtection,
|
protection: SecretProtection,
|
||||||
value: &[u8],
|
value: &[u8],
|
||||||
) -> Result<(), SecretStoreError> {
|
) -> Result<(), SecretStoreError> {
|
||||||
|
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||||
|
if protection == SecretProtection::BiometryCurrentSet {
|
||||||
|
delete_generic_password_options(strict_query(locator)).map_err(map_security_error)?;
|
||||||
|
return strict_set(locator, value);
|
||||||
|
}
|
||||||
let entry = self.entry(locator, protection)?;
|
let entry = self.entry(locator, protection)?;
|
||||||
let existing = entry.get_secret().map_err(map_error)?;
|
let existing = entry.get_secret().map_err(map_error)?;
|
||||||
drop(SecretBytes::new(existing));
|
drop(SecretBytes::new(existing));
|
||||||
@@ -162,6 +187,11 @@ impl SecretStoreBackend for NativeSecretBackend {
|
|||||||
locator: &SecretLocator,
|
locator: &SecretLocator,
|
||||||
protection: SecretProtection,
|
protection: SecretProtection,
|
||||||
) -> Result<(), SecretStoreError> {
|
) -> Result<(), SecretStoreError> {
|
||||||
|
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||||
|
if protection == SecretProtection::BiometryCurrentSet {
|
||||||
|
return delete_generic_password_options(strict_query(locator))
|
||||||
|
.map_err(map_security_error);
|
||||||
|
}
|
||||||
self.entry(locator, protection)?
|
self.entry(locator, protection)?
|
||||||
.delete_credential()
|
.delete_credential()
|
||||||
.map_err(map_error)
|
.map_err(map_error)
|
||||||
@@ -175,6 +205,43 @@ fn presence_modifiers(protection: SecretProtection) -> Option<HashMap<&'static s
|
|||||||
SecretProtection::RequireUserPresence => {
|
SecretProtection::RequireUserPresence => {
|
||||||
Some(HashMap::from([("access-policy", "require-user-presence")]))
|
Some(HashMap::from([("access-policy", "require-user-presence")]))
|
||||||
}
|
}
|
||||||
|
SecretProtection::BiometryCurrentSet => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||||
|
fn strict_query(locator: &SecretLocator) -> PasswordOptions {
|
||||||
|
let (service, account) = locator.service_and_user();
|
||||||
|
let mut options = PasswordOptions::new_generic_password(service, &account);
|
||||||
|
options.use_protected_keychain();
|
||||||
|
options
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||||
|
fn strict_create_options(locator: &SecretLocator) -> Result<PasswordOptions, SecretStoreError> {
|
||||||
|
let access = SecAccessControl::create_with_protection(
|
||||||
|
Some(ProtectionMode::AccessibleWhenPasscodeSetThisDeviceOnly),
|
||||||
|
AccessControlOptions::BIOMETRY_CURRENT_SET.bits(),
|
||||||
|
)
|
||||||
|
.map_err(map_security_error)?;
|
||||||
|
let mut options = strict_query(locator);
|
||||||
|
options.set_access_control(access);
|
||||||
|
Ok(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||||
|
fn strict_set(locator: &SecretLocator, value: &[u8]) -> Result<(), SecretStoreError> {
|
||||||
|
set_generic_password_options(value, strict_create_options(locator)?).map_err(map_security_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||||
|
fn map_security_error(error: security_framework::base::Error) -> SecretStoreError {
|
||||||
|
match error.code() {
|
||||||
|
-25300 => SecretStoreError::Missing,
|
||||||
|
-128 => SecretStoreError::Cancelled,
|
||||||
|
-25293 | -25308 => SecretStoreError::Denied,
|
||||||
|
-50 => SecretStoreError::UnsupportedProtection,
|
||||||
|
_ => SecretStoreError::Unavailable,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ struct BackendState {
|
|||||||
retrieves: usize,
|
retrieves: usize,
|
||||||
locks: usize,
|
locks: usize,
|
||||||
unlocks: usize,
|
unlocks: usize,
|
||||||
|
last_protection: Option<SecretProtection>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
@@ -51,6 +52,14 @@ impl MemoryBackend {
|
|||||||
self.0.lock().expect("test mutex").locks
|
self.0.lock().expect("test mutex").locks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn last_protection(&self) -> Option<SecretProtection> {
|
||||||
|
self.0.lock().expect("test mutex").last_protection
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalidate_enrollment(&self) {
|
||||||
|
self.0.lock().expect("test mutex").values.clear();
|
||||||
|
}
|
||||||
|
|
||||||
fn take_fault(state: &mut BackendState) -> Result<(), SecretStoreError> {
|
fn take_fault(state: &mut BackendState) -> Result<(), SecretStoreError> {
|
||||||
match state.fault.take() {
|
match state.fault.take() {
|
||||||
Some(error) => Err(error),
|
Some(error) => Err(error),
|
||||||
@@ -63,11 +72,12 @@ impl SecretStoreBackend for MemoryBackend {
|
|||||||
fn create(
|
fn create(
|
||||||
&self,
|
&self,
|
||||||
locator: &SecretLocator,
|
locator: &SecretLocator,
|
||||||
_protection: SecretProtection,
|
protection: SecretProtection,
|
||||||
value: &[u8],
|
value: &[u8],
|
||||||
) -> Result<(), SecretStoreError> {
|
) -> Result<(), SecretStoreError> {
|
||||||
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
|
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
|
||||||
Self::take_fault(&mut state)?;
|
Self::take_fault(&mut state)?;
|
||||||
|
state.last_protection = Some(protection);
|
||||||
if state.values.contains_key(locator) {
|
if state.values.contains_key(locator) {
|
||||||
return Err(SecretStoreError::AlreadyExists);
|
return Err(SecretStoreError::AlreadyExists);
|
||||||
}
|
}
|
||||||
@@ -80,10 +90,11 @@ impl SecretStoreBackend for MemoryBackend {
|
|||||||
fn retrieve(
|
fn retrieve(
|
||||||
&self,
|
&self,
|
||||||
locator: &SecretLocator,
|
locator: &SecretLocator,
|
||||||
_protection: SecretProtection,
|
protection: SecretProtection,
|
||||||
) -> Result<SecretBytes, SecretStoreError> {
|
) -> Result<SecretBytes, SecretStoreError> {
|
||||||
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
|
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
|
||||||
Self::take_fault(&mut state)?;
|
Self::take_fault(&mut state)?;
|
||||||
|
state.last_protection = Some(protection);
|
||||||
state.retrieves += 1;
|
state.retrieves += 1;
|
||||||
state
|
state
|
||||||
.values
|
.values
|
||||||
@@ -95,11 +106,12 @@ impl SecretStoreBackend for MemoryBackend {
|
|||||||
fn replace(
|
fn replace(
|
||||||
&self,
|
&self,
|
||||||
locator: &SecretLocator,
|
locator: &SecretLocator,
|
||||||
_protection: SecretProtection,
|
protection: SecretProtection,
|
||||||
value: &[u8],
|
value: &[u8],
|
||||||
) -> Result<(), SecretStoreError> {
|
) -> Result<(), SecretStoreError> {
|
||||||
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
|
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
|
||||||
Self::take_fault(&mut state)?;
|
Self::take_fault(&mut state)?;
|
||||||
|
state.last_protection = Some(protection);
|
||||||
let existing = state
|
let existing = state
|
||||||
.values
|
.values
|
||||||
.get_mut(locator)
|
.get_mut(locator)
|
||||||
@@ -111,10 +123,11 @@ impl SecretStoreBackend for MemoryBackend {
|
|||||||
fn delete(
|
fn delete(
|
||||||
&self,
|
&self,
|
||||||
locator: &SecretLocator,
|
locator: &SecretLocator,
|
||||||
_protection: SecretProtection,
|
protection: SecretProtection,
|
||||||
) -> Result<(), SecretStoreError> {
|
) -> Result<(), SecretStoreError> {
|
||||||
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
|
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
|
||||||
Self::take_fault(&mut state)?;
|
Self::take_fault(&mut state)?;
|
||||||
|
state.last_protection = Some(protection);
|
||||||
state
|
state
|
||||||
.values
|
.values
|
||||||
.remove(locator)
|
.remove(locator)
|
||||||
@@ -356,3 +369,71 @@ fn exact_deadline_races_are_serialized_and_timeout_validation_is_bounded() -> Te
|
|||||||
assert_eq!(backend.locks(), baseline_locks + 1, "expiry relocks once");
|
assert_eq!(backend.locks(), baseline_locks + 1, "expiry relocks once");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verified_manual_recovery_enrolls_current_biometry_and_reestablishes_the_lease() -> TestResult {
|
||||||
|
let fixture = FixtureSet::load()?;
|
||||||
|
let store = fixture.materialize_store("basic")?;
|
||||||
|
let repository = Repository::open(store.path())?;
|
||||||
|
let keys = KeyStore::load(fixture.path("keys"))?;
|
||||||
|
let alice = fixture_key(&fixture, &keys, "alice")?;
|
||||||
|
let backend = MemoryBackend::default();
|
||||||
|
let clock = ManualClock::default();
|
||||||
|
let session = AuthenticationSession::with_clock(
|
||||||
|
backend.clone(),
|
||||||
|
SecretProtectionPolicy::current_biometry_for_openpgp(),
|
||||||
|
AuthenticationTimeout::new(Duration::from_secs(30))?,
|
||||||
|
clock.clone(),
|
||||||
|
);
|
||||||
|
let reader = VaultReader::new(&repository, &keys);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
session.authenticate(&alice),
|
||||||
|
Err(AuthenticationError::SecretStore(SecretStoreError::Missing))
|
||||||
|
));
|
||||||
|
let mut manual = session.authenticate_with_passphrase(
|
||||||
|
&alice,
|
||||||
|
SecretBytes::new(fixture.key("alice")?.passphrase.as_bytes().to_vec()),
|
||||||
|
)?;
|
||||||
|
assert!(matches!(
|
||||||
|
reader.show(Some("email/personal"), &mut manual)?,
|
||||||
|
ShowResult::Entry(_)
|
||||||
|
));
|
||||||
|
manual.persist_passphrase(&alice)?;
|
||||||
|
assert_eq!(
|
||||||
|
backend.last_protection(),
|
||||||
|
Some(SecretProtection::BiometryCurrentSet)
|
||||||
|
);
|
||||||
|
|
||||||
|
session.manual_lock()?;
|
||||||
|
let mut biometric = session.authenticate(&alice)?;
|
||||||
|
assert!(matches!(
|
||||||
|
reader.show(Some("email/personal"), &mut biometric)?,
|
||||||
|
ShowResult::Entry(_)
|
||||||
|
));
|
||||||
|
clock.advance(Duration::from_secs(30));
|
||||||
|
assert!(session.expire()?);
|
||||||
|
assert_eq!(biometric.ensure_active(), Err(AuthenticationError::Expired));
|
||||||
|
|
||||||
|
backend.invalidate_enrollment();
|
||||||
|
assert!(matches!(
|
||||||
|
session.authenticate(&alice),
|
||||||
|
Err(AuthenticationError::SecretStore(SecretStoreError::Missing))
|
||||||
|
));
|
||||||
|
let mut recovered = session.authenticate_with_passphrase(
|
||||||
|
&alice,
|
||||||
|
SecretBytes::new(fixture.key("alice")?.passphrase.as_bytes().to_vec()),
|
||||||
|
)?;
|
||||||
|
assert!(matches!(
|
||||||
|
reader.show(Some("email/personal"), &mut recovered)?,
|
||||||
|
ShowResult::Entry(_)
|
||||||
|
));
|
||||||
|
recovered.persist_passphrase(&alice)?;
|
||||||
|
session.delete_key_passphrase(&alice)?;
|
||||||
|
session.manual_lock()?;
|
||||||
|
assert!(matches!(
|
||||||
|
session.authenticate(&alice),
|
||||||
|
Err(AuthenticationError::SecretStore(SecretStoreError::Missing))
|
||||||
|
));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -210,6 +210,31 @@ fn mobile_tab_defaults_and_persists_through_storage_configuration() -> TestResul
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn biometric_preference_is_secret_free_and_defaults_to_disabled() -> TestResult {
|
||||||
|
let fixture = ConfigurationFixture::new()?;
|
||||||
|
fs::create_dir_all(fixture.temporary.path().join("cwd/vault"))?;
|
||||||
|
fixture.write_explicit(fixture.valid_contents())?;
|
||||||
|
let config = fixture.loader().load(Some(&fixture.explicit_path()))?;
|
||||||
|
assert!(!config.biometric_unlock_enabled());
|
||||||
|
|
||||||
|
config.update_biometric_unlock(true)?;
|
||||||
|
let contents = fs::read_to_string(fixture.explicit_path())?;
|
||||||
|
assert!(contents.contains("biometric_unlock_enabled = true"));
|
||||||
|
assert!(!contents.to_ascii_lowercase().contains("passphrase"));
|
||||||
|
let reloaded = fixture.loader().load(Some(&fixture.explicit_path()))?;
|
||||||
|
assert!(reloaded.biometric_unlock_enabled());
|
||||||
|
|
||||||
|
reloaded.update_biometric_unlock(false)?;
|
||||||
|
assert!(
|
||||||
|
!fixture
|
||||||
|
.loader()
|
||||||
|
.load(Some(&fixture.explicit_path()))?
|
||||||
|
.biometric_unlock_enabled()
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn desktop_vault_switch_preserves_and_reloads_the_shared_configuration() -> TestResult {
|
fn desktop_vault_switch_preserves_and_reloads_the_shared_configuration() -> TestResult {
|
||||||
let fixture = ConfigurationFixture::new()?;
|
let fixture = ConfigurationFixture::new()?;
|
||||||
|
|||||||
Reference in New Issue
Block a user