From 5ae6ef55adcff98bd56dc61ce54b87cabd2c3f3e Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sat, 15 Aug 2026 22:37:31 +0200 Subject: [PATCH] Implement iPhone and Apple Watch preferences (#53) --- apple/Generated/ironstorage_apple.swift | 330 ++++++++++++++++ apple/Generated/ironstorage_appleFFI.h | 55 +++ apple/Sources/App/IronStorageApp.swift | 357 +++++++++++++++--- apple/Watch-Info.plist | 2 + apple/project.yml | 4 + crates/apple/src/lib.rs | 132 ++++++- crates/storage/src/authentication.rs | 40 +- crates/storage/src/config.rs | 104 +++++ crates/storage/src/mobile_authentication.rs | 316 +++++++++++++++- crates/storage/src/secret_store.rs | 23 ++ crates/storage/tests/authentication_leases.rs | 24 ++ crates/storage/tests/config_contract.rs | 29 +- crates/storage/tests/secret_store.rs | 36 +- docs/configuration.md | 10 + 14 files changed, 1402 insertions(+), 60 deletions(-) diff --git a/apple/Generated/ironstorage_apple.swift b/apple/Generated/ironstorage_apple.swift index c62b742..db7eb77 100644 --- a/apple/Generated/ironstorage_apple.swift +++ b/apple/Generated/ironstorage_apple.swift @@ -629,10 +629,16 @@ public protocol MobileAuthenticationProtocol: AnyObject, Sendable { func manualLock() throws + func mobileAppearance() throws -> MobileAppearance + func performEntryMutation(request: MobileMutationRequest) throws -> MobileMutationOutcome + func preferences(watchSupported: Bool, watchPaired: Bool, watchAppInstalled: Bool) throws -> MobilePreferences + func prepareEntryMutation(path: String, action: MobileMutationAction) throws -> MobileMutationPlan + func removeApplicationToken() throws + func removeEntryEditorField(editor: UInt64, field: UInt64) throws -> MobileEntryEditorPage func reorderEntryEditorField(editor: UInt64, field: UInt64, index: UInt32) throws -> MobileEntryEditorPage @@ -645,10 +651,14 @@ public protocol MobileAuthenticationProtocol: AnyObject, Sendable { func searchCachedTotpPage(query: String) throws -> MobileTotpPage? + func setAuthenticationTimeout(seconds: UInt64) throws + func setBiometricUnlock(enabled: Bool) throws -> MobileAuthenticationState func setGitIdentity(name: String, email: String) throws -> MobileGitIdentity + func setMobileAppearance(appearance: MobileAppearance) throws + func setTotpWatchShared(path: String, shared: Bool, unixSeconds: UInt64) throws -> MobileTotpDetail func state() throws -> MobileAuthenticationState @@ -852,6 +862,15 @@ open func manualLock()throws {try rustCallWithError(FfiConverterTypeMobileAuth } } +open func mobileAppearance()throws -> MobileAppearance { + return try FfiConverterTypeMobileAppearance_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobileauthentication_mobile_appearance( + self.uniffiCloneHandle(),uniffiCallStatus + ) +}) +} + open func performEntryMutation(request: MobileMutationRequest)throws -> MobileMutationOutcome { return try FfiConverterTypeMobileMutationOutcome_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { uniffiCallStatus in @@ -862,6 +881,18 @@ open func performEntryMutation(request: MobileMutationRequest)throws -> MobileM }) } +open func preferences(watchSupported: Bool, watchPaired: Bool, watchAppInstalled: Bool)throws -> MobilePreferences { + return try FfiConverterTypeMobilePreferences_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobileauthentication_preferences( + self.uniffiCloneHandle(), + FfiConverterBool.lower(watchSupported), + FfiConverterBool.lower(watchPaired), + FfiConverterBool.lower(watchAppInstalled),uniffiCallStatus + ) +}) +} + open func prepareEntryMutation(path: String, action: MobileMutationAction)throws -> MobileMutationPlan { return try FfiConverterTypeMobileMutationPlan_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { uniffiCallStatus in @@ -873,6 +904,14 @@ open func prepareEntryMutation(path: String, action: MobileMutationAction)throws }) } +open func removeApplicationToken()throws {try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobileauthentication_remove_application_token( + self.uniffiCloneHandle(),uniffiCallStatus + ) +} +} + open func removeEntryEditorField(editor: UInt64, field: UInt64)throws -> MobileEntryEditorPage { return try FfiConverterTypeMobileEntryEditorPage_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { uniffiCallStatus in @@ -940,6 +979,15 @@ open func searchCachedTotpPage(query: String)throws -> MobileTotpPage? { }) } +open func setAuthenticationTimeout(seconds: UInt64)throws {try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobileauthentication_set_authentication_timeout( + self.uniffiCloneHandle(), + FfiConverterUInt64.lower(seconds),uniffiCallStatus + ) +} +} + open func setBiometricUnlock(enabled: Bool)throws -> MobileAuthenticationState { return try FfiConverterTypeMobileAuthenticationState_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { uniffiCallStatus in @@ -961,6 +1009,15 @@ open func setGitIdentity(name: String, email: String)throws -> MobileGitIdentit }) } +open func setMobileAppearance(appearance: MobileAppearance)throws {try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobileauthentication_set_mobile_appearance( + self.uniffiCloneHandle(), + FfiConverterTypeMobileAppearance_lower(appearance),uniffiCallStatus + ) +} +} + open func setTotpWatchShared(path: String, shared: Bool, unixSeconds: UInt64)throws -> MobileTotpDetail { return try FfiConverterTypeMobileTotpDetail_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { uniffiCallStatus in @@ -3962,6 +4019,104 @@ public func FfiConverterTypeMobilePasswordRow_lower(_ value: MobilePasswordRow) } +public struct MobilePreferences: Equatable, Hashable { + public var repositoryTitle: String + public var repositoryUrl: String + public var serverTitle: String + public var serverIdentity: String + public var applicationAccount: String? + public var defaultKeyTitle: String + public var defaultKeyFingerprint: String + public var authenticationTimeoutSeconds: UInt64 + public var biometricUnlockEnabled: Bool + public var appearance: MobileAppearance + public var watchState: MobileWatchPreferenceState + public var watchTitle: String + public var watchDetail: String + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(repositoryTitle: String, repositoryUrl: String, serverTitle: String, serverIdentity: String, applicationAccount: String?, defaultKeyTitle: String, defaultKeyFingerprint: String, authenticationTimeoutSeconds: UInt64, biometricUnlockEnabled: Bool, appearance: MobileAppearance, watchState: MobileWatchPreferenceState, watchTitle: String, watchDetail: String) { + self.repositoryTitle = repositoryTitle + self.repositoryUrl = repositoryUrl + self.serverTitle = serverTitle + self.serverIdentity = serverIdentity + self.applicationAccount = applicationAccount + self.defaultKeyTitle = defaultKeyTitle + self.defaultKeyFingerprint = defaultKeyFingerprint + self.authenticationTimeoutSeconds = authenticationTimeoutSeconds + self.biometricUnlockEnabled = biometricUnlockEnabled + self.appearance = appearance + self.watchState = watchState + self.watchTitle = watchTitle + self.watchDetail = watchDetail + } + + + + +} + +#if compiler(>=6) +extension MobilePreferences: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobilePreferences: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobilePreferences { + return + try MobilePreferences( + repositoryTitle: FfiConverterString.read(from: &buf), + repositoryUrl: FfiConverterString.read(from: &buf), + serverTitle: FfiConverterString.read(from: &buf), + serverIdentity: FfiConverterString.read(from: &buf), + applicationAccount: FfiConverterOptionString.read(from: &buf), + defaultKeyTitle: FfiConverterString.read(from: &buf), + defaultKeyFingerprint: FfiConverterString.read(from: &buf), + authenticationTimeoutSeconds: FfiConverterUInt64.read(from: &buf), + biometricUnlockEnabled: FfiConverterBool.read(from: &buf), + appearance: FfiConverterTypeMobileAppearance.read(from: &buf), + watchState: FfiConverterTypeMobileWatchPreferenceState.read(from: &buf), + watchTitle: FfiConverterString.read(from: &buf), + watchDetail: FfiConverterString.read(from: &buf) + ) + } + + public static func write(_ value: MobilePreferences, into buf: inout [UInt8]) { + FfiConverterString.write(value.repositoryTitle, into: &buf) + FfiConverterString.write(value.repositoryUrl, into: &buf) + FfiConverterString.write(value.serverTitle, into: &buf) + FfiConverterString.write(value.serverIdentity, into: &buf) + FfiConverterOptionString.write(value.applicationAccount, into: &buf) + FfiConverterString.write(value.defaultKeyTitle, into: &buf) + FfiConverterString.write(value.defaultKeyFingerprint, into: &buf) + FfiConverterUInt64.write(value.authenticationTimeoutSeconds, into: &buf) + FfiConverterBool.write(value.biometricUnlockEnabled, into: &buf) + FfiConverterTypeMobileAppearance.write(value.appearance, into: &buf) + FfiConverterTypeMobileWatchPreferenceState.write(value.watchState, into: &buf) + FfiConverterString.write(value.watchTitle, into: &buf) + FfiConverterString.write(value.watchDetail, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobilePreferences_lift(_ buf: RustBuffer) throws -> MobilePreferences { + return try FfiConverterTypeMobilePreferences.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobilePreferences_lower(_ value: MobilePreferences) -> RustBuffer { + return FfiConverterTypeMobilePreferences.lower(value) +} + + public struct MobileShell: Equatable, Hashable { public var selectedTab: MobileTab public var pages: [MobilePage] @@ -4351,6 +4506,79 @@ public func FfiConverterTypeMobileWatchSnapshotStatus_lower(_ value: MobileWatch +public enum MobileAppearance: Equatable, Hashable { + + case system + case light + case dark + + + + + +} + +#if compiler(>=6) +extension MobileAppearance: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileAppearance: FfiConverterRustBuffer { + typealias SwiftType = MobileAppearance + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileAppearance { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .system + + case 2: return .light + + case 3: return .dark + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: MobileAppearance, into buf: inout [UInt8]) { + switch value { + + + case .system: + writeInt(&buf, Int32(1)) + + + case .light: + writeInt(&buf, Int32(2)) + + + case .dark: + writeInt(&buf, Int32(3)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileAppearance_lift(_ buf: RustBuffer) throws -> MobileAppearance { + return try FfiConverterTypeMobileAppearance.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileAppearance_lower(_ value: MobileAppearance) -> RustBuffer { + return FfiConverterTypeMobileAppearance.lower(value) +} + + + + public enum MobileAuthenticationErrorKind: Equatable, Hashable { case passphraseRequired @@ -6383,6 +6611,93 @@ public func FfiConverterTypeMobileTotpDiscoveryPhase_lower(_ value: MobileTotpDi +public enum MobileWatchPreferenceState: Equatable, Hashable { + + case unsupported + case notPaired + case appNotInstalled + case ready + case pending + + + + + +} + +#if compiler(>=6) +extension MobileWatchPreferenceState: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileWatchPreferenceState: FfiConverterRustBuffer { + typealias SwiftType = MobileWatchPreferenceState + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileWatchPreferenceState { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .unsupported + + case 2: return .notPaired + + case 3: return .appNotInstalled + + case 4: return .ready + + case 5: return .pending + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: MobileWatchPreferenceState, into buf: inout [UInt8]) { + switch value { + + + case .unsupported: + writeInt(&buf, Int32(1)) + + + case .notPaired: + writeInt(&buf, Int32(2)) + + + case .appNotInstalled: + writeInt(&buf, Int32(3)) + + + case .ready: + writeInt(&buf, Int32(4)) + + + case .pending: + writeInt(&buf, Int32(5)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileWatchPreferenceState_lift(_ buf: RustBuffer) throws -> MobileWatchPreferenceState { + return try FfiConverterTypeMobileWatchPreferenceState.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileWatchPreferenceState_lower(_ value: MobileWatchPreferenceState) -> RustBuffer { + return FfiConverterTypeMobileWatchPreferenceState.lower(value) +} + + + + public enum MobileWatchSnapshotState: Equatable, Hashable { case unavailable @@ -7221,12 +7536,21 @@ private let initializationResult: InitializationResult = { if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_manual_lock() != 57220) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_mobile_appearance() != 3564) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_perform_entry_mutation() != 7053) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_preferences() != 16444) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_prepare_entry_mutation() != 6234) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_remove_application_token() != 11452) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_remove_entry_editor_field() != 12238) { return InitializationResult.apiChecksumMismatch } @@ -7245,12 +7569,18 @@ private let initializationResult: InitializationResult = { if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_search_cached_totp_page() != 13608) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_authentication_timeout() != 48083) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_biometric_unlock() != 9486) { return InitializationResult.apiChecksumMismatch } if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_git_identity() != 62371) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_mobile_appearance() != 12133) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_totp_watch_shared() != 57472) { return InitializationResult.apiChecksumMismatch } diff --git a/apple/Generated/ironstorage_appleFFI.h b/apple/Generated/ironstorage_appleFFI.h index 991e215..3b32b2b 100644 --- a/apple/Generated/ironstorage_appleFFI.h +++ b/apple/Generated/ironstorage_appleFFI.h @@ -318,16 +318,31 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_git_identity( 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_MOBILE_APPEARANCE +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_MOBILE_APPEARANCE +RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_mobile_appearance(uint64_t ptr, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PERFORM_ENTRY_MUTATION #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PERFORM_ENTRY_MUTATION RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_perform_entry_mutation(uint64_t ptr, RustBuffer request, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PREFERENCES +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PREFERENCES +RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_preferences(uint64_t ptr, int8_t watch_supported, int8_t watch_paired, int8_t watch_app_installed, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PREPARE_ENTRY_MUTATION #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PREPARE_ENTRY_MUTATION RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_prepare_entry_mutation(uint64_t ptr, RustBuffer path, RustBuffer action, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_REMOVE_APPLICATION_TOKEN +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_REMOVE_APPLICATION_TOKEN +void uniffi_ironstorage_apple_fn_method_mobileauthentication_remove_application_token(uint64_t ptr, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_REMOVE_ENTRY_EDITOR_FIELD #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_REMOVE_ENTRY_EDITOR_FIELD RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_remove_entry_editor_field(uint64_t ptr, uint64_t editor, uint64_t field, RustCallStatus *_Nonnull out_status @@ -358,6 +373,11 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_save_entry_ed RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_search_cached_totp_page(uint64_t ptr, RustBuffer query, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_AUTHENTICATION_TIMEOUT +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_AUTHENTICATION_TIMEOUT +void uniffi_ironstorage_apple_fn_method_mobileauthentication_set_authentication_timeout(uint64_t ptr, uint64_t seconds, 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 @@ -368,6 +388,11 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_set_biometric RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_set_git_identity(uint64_t ptr, RustBuffer name, RustBuffer email, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_MOBILE_APPEARANCE +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_MOBILE_APPEARANCE +void uniffi_ironstorage_apple_fn_method_mobileauthentication_set_mobile_appearance(uint64_t ptr, RustBuffer appearance, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_TOTP_WATCH_SHARED #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_TOTP_WATCH_SHARED RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_set_totp_watch_shared(uint64_t ptr, RustBuffer path, int8_t shared, uint64_t unix_seconds, RustCallStatus *_Nonnull out_status @@ -1026,18 +1051,36 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_git_ident #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_MOBILE_APPEARANCE +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_MOBILE_APPEARANCE +uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_mobile_appearance(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PERFORM_ENTRY_MUTATION #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PERFORM_ENTRY_MUTATION uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_perform_entry_mutation(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PREFERENCES +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PREFERENCES +uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_preferences(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PREPARE_ENTRY_MUTATION #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PREPARE_ENTRY_MUTATION uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_prepare_entry_mutation(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_REMOVE_APPLICATION_TOKEN +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_REMOVE_APPLICATION_TOKEN +uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_remove_application_token(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_REMOVE_ENTRY_EDITOR_FIELD @@ -1074,6 +1117,12 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_save_entr #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SEARCH_CACHED_TOTP_PAGE uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_search_cached_totp_page(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_AUTHENTICATION_TIMEOUT +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_AUTHENTICATION_TIMEOUT +uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_authentication_timeout(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK @@ -1086,6 +1135,12 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_biome #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_GIT_IDENTITY uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_git_identity(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_MOBILE_APPEARANCE +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_MOBILE_APPEARANCE +uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_mobile_appearance(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_TOTP_WATCH_SHARED diff --git a/apple/Sources/App/IronStorageApp.swift b/apple/Sources/App/IronStorageApp.swift index 8ea3ea4..7604f51 100644 --- a/apple/Sources/App/IronStorageApp.swift +++ b/apple/Sources/App/IronStorageApp.swift @@ -2,6 +2,7 @@ import UIKit import AVFoundation import Vision import VisionKit +import WatchConnectivity extension Notification.Name { static let ironStorageLocalStoreDidChange = Notification.Name( @@ -107,6 +108,13 @@ private final class AppContext: NSObject, UITabBarControllerDelegate { } tabs.viewControllers = navigationControllers tabs.delegate = self + if let appearance = try? authentication?.mobileAppearance() { + tabs.overrideUserInterfaceStyle = switch appearance { + case .system: .unspecified + case .light: .light + case .dark: .dark + } + } restoreSelectedTab() monitorAuthentication() return tabs @@ -844,13 +852,17 @@ private final class ShellViewController: UITableViewController, MobileTabRoot { } @MainActor -private final class PreferencesViewController: UITableViewController, MobileTabRoot { +private final class PreferencesViewController: UITableViewController, MobileTabRoot, + WCSessionDelegate +{ fileprivate let shellTab = MobileTab.preferences private var page: MobilePage private let authentication: MobileAuthentication? private let keyTransfer = try? mobileKeyTransfer() private var state: MobileAuthenticationState? + private var preferences: MobilePreferences? private var gitIdentity: MobileGitIdentity? + private var watchSession: WCSession? private var preferenceTask: Task? private var loadTask: Task? private var loadGeneration = 0 @@ -859,6 +871,12 @@ private final class PreferencesViewController: UITableViewController, MobileTabR self.page = page self.authentication = authentication super.init(style: .insetGrouped) + if WCSession.isSupported() { + let session = WCSession.default + watchSession = session + session.delegate = self + session.activate() + } title = page.title navigationItem.largeTitleDisplayMode = .always navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -881,6 +899,7 @@ private final class PreferencesViewController: UITableViewController, MobileTabR } deinit { + watchSession?.delegate = nil preferenceTask?.cancel() loadTask?.cancel() NotificationCenter.default.removeObserver(self) @@ -892,14 +911,18 @@ private final class PreferencesViewController: UITableViewController, MobileTabR } override func numberOfSections(in tableView: UITableView) -> Int { - page.state == .ready ? 4 : 0 + page.state == .ready ? 7 : 0 } override func tableView( _ tableView: UITableView, numberOfRowsInSection section: Int ) -> Int { - section == 1 ? 2 : (section == 3 ? 2 : 1) + switch section { + case 0: 2 + case 1, 2, 3: 3 + default: 1 + } } override func tableView( @@ -907,10 +930,13 @@ private final class PreferencesViewController: UITableViewController, MobileTabR titleForHeaderInSection section: Int ) -> String? { switch section { - case 0: "Git Commit Identity" - case 1: "GPG Key Transfer" - case 2: "Secure Unlock" - default: "Authentication Session" + case 0: "Password Store" + case 1: "Application Token" + case 2: "GPG Key & Recovery" + case 3: "Authentication" + case 4: "Appearance" + case 5: "Apple Watch" + default: "Git Commit Identity" } } @@ -918,16 +944,22 @@ private final class PreferencesViewController: UITableViewController, MobileTabR _ tableView: UITableView, titleForFooterInSection section: Int ) -> String? { - if section == 0 { - return "Used as the author for commits created by the iPhone app." + switch section { + case 0: + "Non-secret repository and server identities from the shared configuration." + case 1: + "The application token is stored only in protected system storage and is never displayed." + case 2: + "Import provides initial or recovery key setup. Private-key transfers require explicit confirmation and passphrase validation." + case 3: + "Manual lock and inactivity expiry immediately revoke the shared Rust authentication lease." + case 5: + "Pairing is read from the device; TOTP selection and synchronization state come from Rust." + case 6: + "Used as the author for commits created by the iPhone app." + default: + nil } - if section == 1 { - return "Scan or display ASCII-armored GPG keys. Private-key transfers require explicit confirmation and passphrase validation." - } - if section == 2 { - 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( @@ -936,24 +968,58 @@ private final class PreferencesViewController: UITableViewController, MobileTabR ) -> UITableViewCell { let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil) var content = cell.defaultContentConfiguration() - if indexPath.section == 0 { - content.image = UIImage(systemName: "person.crop.circle") - content.text = gitIdentity?.name ?? "Git Commit Identity" - content.secondaryText = gitIdentity?.email ?? "Set the commit author name and email" + switch (indexPath.section, indexPath.row) { + case (0, 0): + content.image = UIImage(systemName: "externaldrive.fill") + content.text = preferences?.repositoryTitle ?? "Password Store" + content.secondaryText = preferences?.repositoryUrl ?? "Repository unavailable" + cell.selectionStyle = .none + case (0, 1): + content.image = UIImage(systemName: "server.rack") + content.text = preferences?.serverTitle ?? "HTTPS Server" + content.secondaryText = preferences?.serverIdentity ?? "Server identity unavailable" + cell.selectionStyle = .none + case (1, 0): + content.image = UIImage(systemName: "key.fill") + content.text = preferences?.applicationAccount ?? "No Application Token" + content.secondaryText = preferences?.applicationAccount == nil + ? "Git network operations require a replacement token" + : "Token stored in protected system storage" + cell.selectionStyle = .none + case (1, 1): + content.image = UIImage(systemName: "arrow.triangle.2.circlepath") + content.text = "Replace Token" + content.secondaryText = "Store a new HTTPS application token" cell.accessoryType = .disclosureIndicator - cell.isUserInteractionEnabled = authentication != nil - cell.contentView.alpha = authentication == nil ? 0.45 : 1 - } else if indexPath.section == 1 { - let importing = indexPath.row == 0 + case (1, 2): + content.image = UIImage(systemName: "trash") + content.text = "Remove Token" + content.textProperties.color = .systemRed + content.secondaryText = "Disable authenticated Git network operations" + cell.accessoryType = .disclosureIndicator + cell.isUserInteractionEnabled = preferences?.applicationAccount != nil + cell.contentView.alpha = preferences?.applicationAccount == nil ? 0.45 : 1 + case (2, 0): + content.image = UIImage(systemName: "person.badge.key.fill") + content.text = preferences?.defaultKeyTitle ?? "Default GPG Key" + content.secondaryText = preferences?.defaultKeyFingerprint ?? "Key unavailable" + cell.selectionStyle = .none + case (2, 1), (2, 2): + let importing = indexPath.row == 1 content.image = UIImage(systemName: importing ? "qrcode.viewfinder" : "qrcode") content.text = importing ? "Import GPG Key" : "Export GPG Key" content.secondaryText = importing - ? "Scan one or more transfer QR codes" + ? "Scan an initial or recovery key transfer" : "Display public or private key armor" cell.accessoryType = .disclosureIndicator cell.isUserInteractionEnabled = keyTransfer != nil cell.contentView.alpha = keyTransfer == nil ? 0.45 : 1 - } else if indexPath.section == 2 { + case (3, 0): + content.image = UIImage(systemName: "timer") + content.text = "Inactivity Timeout" + content.secondaryText = "\(preferences?.authenticationTimeoutSeconds ?? 0) seconds" + cell.accessoryType = .disclosureIndicator + case (3, 1): content.image = UIImage(systemName: "faceid") content.text = "Biometric Unlock" content.secondaryText = state?.biometricUnlockEnabled == true @@ -966,37 +1032,51 @@ private final class PreferencesViewController: UITableViewController, MobileTabR toggle.accessibilityLabel = "Biometric Unlock" cell.accessoryView = toggle cell.selectionStyle = .none - } else if indexPath.row == 0 { + case (3, 2): 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" + content.secondaryText = unlocked + ? "Revoke all active authentication handles" + : "IronStorage is already locked" + cell.accessoryType = .disclosureIndicator + cell.isUserInteractionEnabled = unlocked + cell.contentView.alpha = unlocked ? 1 : 0.45 + case (4, 0): + content.image = UIImage(systemName: "circle.lefthalf.filled") + content.text = "App Appearance" + content.secondaryText = appearanceTitle(preferences?.appearance ?? .system) + cell.accessoryType = .disclosureIndicator + case (5, 0): + content.image = UIImage(systemName: watchImage(preferences?.watchState)) + content.text = preferences?.watchTitle ?? "Checking Apple Watch" + content.secondaryText = preferences?.watchDetail ?? "Reading pairing state" + cell.accessoryType = .disclosureIndicator + default: + content.image = UIImage(systemName: "person.crop.circle") + content.text = gitIdentity?.name ?? "Git Commit Identity" + content.secondaryText = gitIdentity?.email ?? "Set the commit author name and email" cell.accessoryType = .disclosureIndicator - cell.isUserInteractionEnabled = state?.unlocked == true - cell.contentView.alpha = state?.unlocked == true ? 1 : 0.45 } content.secondaryTextProperties.numberOfLines = 0 cell.contentConfiguration = content + cell.accessibilityLabel = [content.text, content.secondaryText] + .compactMap { $0 } + .joined(separator: ", ") return cell } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) - if indexPath.section == 0 { - editGitIdentity() - return - } - if indexPath.section == 1 { + switch (indexPath.section, indexPath.row) { + case (1, 1): + tokenUpdateRequested() + case (1, 2): + removeTokenRequested() + case (2, 1), (2, 2): guard let keyTransfer else { return } - if indexPath.row == 0 { + if indexPath.row == 1 { requestKeyScanner(keyTransfer) } else { navigationController?.pushViewController( @@ -1004,9 +1084,23 @@ private final class PreferencesViewController: UITableViewController, MobileTabR animated: true ) } - return + case (3, 0): + editAuthenticationTimeout() + case (3, 2): + lockNow() + case (4, 0): + chooseAppearance() + case (5, 0): + openWatchPreference() + case (6, 0): + editGitIdentity() + default: + break } - guard indexPath.section == 3, indexPath.row == 1, let authentication else { return } + } + + private func lockNow() { + guard let authentication else { return } do { try authentication.manualLock() refreshState() @@ -1188,7 +1282,7 @@ private final class PreferencesViewController: UITableViewController, MobileTabR switch result { case let .success(identity): gitIdentity = identity - tableView.reloadSections(IndexSet(integer: 0), with: .automatic) + tableView.reloadSections(IndexSet(integer: 6), with: .automatic) UIAccessibility.post( notification: .announcement, argument: "Git commit identity updated" @@ -1199,6 +1293,147 @@ private final class PreferencesViewController: UITableViewController, MobileTabR } } + private func removeTokenRequested() { + guard preferences?.applicationAccount != nil else { return } + let alert = UIAlertController( + title: "Remove Application Token?", + message: "Local password browsing remains available, but Fetch, Pull, and Push will fail until a replacement token is stored.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + alert.addAction(UIAlertAction(title: "Remove Token", style: .destructive) { + [weak self] _ in + self?.runPreferenceUpdate(announcement: "Application token removed") { + try self?.authentication?.removeApplicationToken() + } + }) + present(alert, animated: true) + } + + private func editAuthenticationTimeout() { + let alert = UIAlertController( + title: "Inactivity Timeout", + message: "Enter a shared timeout from 1 to 86400 seconds.", + preferredStyle: .alert + ) + alert.addTextField { [preferences] field in + field.keyboardType = .numberPad + field.text = String(preferences?.authenticationTimeoutSeconds ?? 120) + field.accessibilityLabel = "Timeout in seconds" + } + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + alert.addAction(UIAlertAction(title: "Save", style: .default) { + [weak self, weak alert] _ in + let seconds = UInt64(alert?.textFields?.first?.text ?? "") ?? 0 + self?.runPreferenceUpdate(announcement: "Inactivity timeout updated") { + try self?.authentication?.setAuthenticationTimeout(seconds: seconds) + } + }) + present(alert, animated: true) + } + + private func chooseAppearance() { + let alert = UIAlertController(title: "App Appearance", message: nil, preferredStyle: .actionSheet) + for appearance in [MobileAppearance.system, .light, .dark] { + let selected = appearance == preferences?.appearance + alert.addAction(UIAlertAction( + title: "\(selected ? "✓ " : "")\(appearanceTitle(appearance))", + style: .default + ) { [weak self] _ in + self?.runPreferenceUpdate(announcement: "Appearance updated") { + try self?.authentication?.setMobileAppearance(appearance: appearance) + } + }) + } + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + alert.popoverPresentationController?.sourceView = tableView + alert.popoverPresentationController?.sourceRect = tableView.bounds + present(alert, animated: true) + } + + private func runPreferenceUpdate( + announcement: String, + operation: @escaping @Sendable () throws -> Void + ) { + preferenceTask?.cancel() + preferenceTask = Task { [weak self] in + let result = await Task.detached(priority: .userInitiated) { + do { + try operation() + return Result.success(()) + } catch let error as MobileAuthenticationFfiError { + return .failure(AuthenticationFailure(error)) + } catch { + return .failure(.unexpected) + } + }.value + guard !Task.isCancelled, let self else { return } + switch result { + case .success: + refreshState() + UIAccessibility.post(notification: .announcement, argument: announcement) + case let .failure(failure): + presentAuthenticationFailure(failure) + } + } + } + + private func openWatchPreference() { + switch preferences?.watchState { + case .ready, .pending: + selectTotpTab() + case .notPaired: + presentWatchGuidance( + title: "Pair an Apple Watch", + message: "Open the Watch app on this iPhone and pair an Apple Watch before configuring IronStorage synchronization." + ) + case .appNotInstalled: + presentWatchGuidance( + title: "Install IronStorage on Apple Watch", + message: "Open the Watch app on this iPhone and install the IronStorage companion on the paired watch." + ) + default: + presentWatchGuidance( + title: "Apple Watch Unavailable", + message: "Apple Watch connectivity is unavailable on this device." + ) + } + } + + private func presentWatchGuidance(title: String, message: String) { + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + present(alert, animated: true) + } + + private func selectTotpTab() { + guard let controllers = tabBarController?.viewControllers else { return } + tabBarController?.selectedIndex = controllers.firstIndex { controller in + guard + let navigation = controller as? UINavigationController, + let root = navigation.viewControllers.first as? MobileTabRoot + else { return false } + return root.shellTab == .totp + } ?? tabBarController?.selectedIndex ?? 0 + } + + private func appearanceTitle(_ appearance: MobileAppearance) -> String { + switch appearance { + case .system: "System" + case .light: "Light" + case .dark: "Dark" + } + } + + private func watchImage(_ state: MobileWatchPreferenceState?) -> String { + switch state { + case .ready: "applewatch.radiowaves.left.and.right" + case .pending: "arrow.triangle.2.circlepath" + case .notPaired, .appNotInstalled: "applewatch.slash" + default: "applewatch" + } + } + @objc private func authenticationDidChange() { refreshState() } @@ -1244,8 +1479,38 @@ private final class PreferencesViewController: UITableViewController, MobileTabR private func refreshState() { state = try? authentication?.state() gitIdentity = try? authentication?.gitIdentity() + preferences = try? authentication?.preferences( + watchSupported: WCSession.isSupported(), + watchPaired: watchSession?.isPaired ?? false, + watchAppInstalled: watchSession?.isWatchAppInstalled ?? false + ) + if let appearance = preferences?.appearance { + tabBarController?.overrideUserInterfaceStyle = switch appearance { + case .system: .unspecified + case .light: .light + case .dark: .dark + } + } tableView.reloadData() } + + nonisolated func session( + _ session: WCSession, + activationDidCompleteWith activationState: WCSessionActivationState, + error: Error? + ) { + Task { @MainActor [weak self] in self?.refreshState() } + } + + nonisolated func sessionDidBecomeInactive(_ session: WCSession) {} + + nonisolated func sessionDidDeactivate(_ session: WCSession) { + session.activate() + } + + nonisolated func sessionWatchStateDidChange(_ session: WCSession) { + Task { @MainActor [weak self] in self?.refreshState() } + } } @MainActor diff --git a/apple/Watch-Info.plist b/apple/Watch-Info.plist index 6f14547..200d933 100644 --- a/apple/Watch-Info.plist +++ b/apple/Watch-Info.plist @@ -20,6 +20,8 @@ 1.0 CFBundleVersion 1 + WKApplication + WKCompanionAppBundleIdentifier de.rfc1437.ironstorage diff --git a/apple/project.yml b/apple/project.yml index d68b23d..c4386a1 100644 --- a/apple/project.yml +++ b/apple/project.yml @@ -31,6 +31,9 @@ targets: - Assets.xcassets - Sources/App - Generated/ironstorage_apple.swift + dependencies: + - target: IronStorageWatch + embed: true preBuildScripts: - name: Build Rust core basedOnDependencyAnalysis: false @@ -78,6 +81,7 @@ targets: path: Watch-Info.plist properties: CFBundleDisplayName: IronStorage + WKApplication: true WKCompanionAppBundleIdentifier: de.rfc1437.ironstorage sources: - Sources/Watch diff --git a/crates/apple/src/lib.rs b/crates/apple/src/lib.rs index df26a03..1df69f8 100644 --- a/crates/apple/src/lib.rs +++ b/crates/apple/src/lib.rs @@ -10,13 +10,15 @@ use std::{ }; use ironstorage::{ - config::ConfigError, + config::{ConfigError, MobileAppearance as StorageMobileAppearance}, mobile::{self, MobileShellState as StorageShellState, MobileTab as StorageTab}, mobile_authentication::{ MobileAuthenticationError as StorageAuthenticationError, MobileAuthenticationErrorKind as StorageAuthenticationErrorKind, MobileAuthenticationState as StorageAuthenticationState, MobileEntryCopy as StorageEntryCopy, MobileEntryPresentation as StorageEntryPresentation, + MobilePreferences as StorageMobilePreferences, + MobileWatchPreferenceState as StorageWatchPreferenceState, }, mobile_entry::{ MobileEntryEditorFieldKind as StorageEntryEditorFieldKind, @@ -623,6 +625,91 @@ pub struct MobileAuthenticationState { pub remaining_seconds: u64, } +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum MobileAppearance { + System, + Light, + Dark, +} + +impl From for MobileAppearance { + fn from(appearance: StorageMobileAppearance) -> Self { + match appearance { + StorageMobileAppearance::System => Self::System, + StorageMobileAppearance::Light => Self::Light, + StorageMobileAppearance::Dark => Self::Dark, + } + } +} + +impl From for StorageMobileAppearance { + fn from(appearance: MobileAppearance) -> Self { + match appearance { + MobileAppearance::System => Self::System, + MobileAppearance::Light => Self::Light, + MobileAppearance::Dark => Self::Dark, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum MobileWatchPreferenceState { + Unsupported, + NotPaired, + AppNotInstalled, + Ready, + Pending, +} + +impl From for MobileWatchPreferenceState { + fn from(state: StorageWatchPreferenceState) -> Self { + match state { + StorageWatchPreferenceState::Unsupported => Self::Unsupported, + StorageWatchPreferenceState::NotPaired => Self::NotPaired, + StorageWatchPreferenceState::AppNotInstalled => Self::AppNotInstalled, + StorageWatchPreferenceState::Ready => Self::Ready, + StorageWatchPreferenceState::Pending => Self::Pending, + } + } +} + +#[derive(Clone, uniffi::Record)] +pub struct MobilePreferences { + pub repository_title: String, + pub repository_url: String, + pub server_title: String, + pub server_identity: String, + pub application_account: Option, + pub default_key_title: String, + pub default_key_fingerprint: String, + pub authentication_timeout_seconds: u64, + pub biometric_unlock_enabled: bool, + pub appearance: MobileAppearance, + pub watch_state: MobileWatchPreferenceState, + pub watch_title: String, + pub watch_detail: String, +} + +impl From for MobilePreferences { + fn from(preferences: StorageMobilePreferences) -> Self { + Self { + repository_title: preferences.repository_title().to_owned(), + repository_url: preferences.repository_url().to_owned(), + server_title: preferences.server_title().to_owned(), + server_identity: preferences.server_identity().to_owned(), + application_account: preferences.application_account().map(str::to_owned), + default_key_title: preferences.default_key_title().to_owned(), + default_key_fingerprint: preferences.default_key_fingerprint().to_owned(), + authentication_timeout_seconds: preferences.authentication_timeout_seconds(), + biometric_unlock_enabled: preferences.biometric_unlock_enabled(), + appearance: preferences.appearance().into(), + watch_state: preferences.watch_state().into(), + watch_title: preferences.watch_title().to_owned(), + watch_detail: preferences.watch_detail().to_owned(), + } + } +} + #[derive(Clone, uniffi::Record)] pub struct MobileGitIdentity { pub name: String, @@ -1400,6 +1487,49 @@ impl MobileAuthentication { .map_err(Into::into) } + pub fn mobile_appearance(&self) -> Result { + self.authentication + .mobile_appearance() + .map(Into::into) + .map_err(Into::into) + } + + pub fn preferences( + &self, + watch_supported: bool, + watch_paired: bool, + watch_app_installed: bool, + ) -> Result { + self.authentication + .preferences(watch_supported, watch_paired, watch_app_installed) + .map(Into::into) + .map_err(Into::into) + } + + pub fn set_authentication_timeout( + &self, + seconds: u64, + ) -> Result<(), MobileAuthenticationFfiError> { + self.authentication + .set_authentication_timeout(seconds) + .map_err(Into::into) + } + + pub fn set_mobile_appearance( + &self, + appearance: MobileAppearance, + ) -> Result<(), MobileAuthenticationFfiError> { + self.authentication + .set_mobile_appearance(appearance.into()) + .map_err(Into::into) + } + + pub fn remove_application_token(&self) -> Result<(), MobileAuthenticationFfiError> { + self.authentication + .remove_application_token() + .map_err(Into::into) + } + pub fn set_git_identity( &self, name: String, diff --git a/crates/storage/src/authentication.rs b/crates/storage/src/authentication.rs index 11bd755..fa23383 100644 --- a/crates/storage/src/authentication.rs +++ b/crates/storage/src/authentication.rs @@ -134,7 +134,7 @@ struct LeaseState { struct Shared { store: SecretStore, - timeout: AuthenticationTimeout, + timeout: Mutex, clock: C, /// Serializes access checks with relock, eliminating check-then-use races. operation: Mutex<()>, @@ -168,9 +168,14 @@ impl Clone for AuthenticationSess impl fmt::Debug for AuthenticationSession { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let timeout = self + .shared + .timeout + .lock() + .map_or_else(|poisoned| *poisoned.into_inner(), |timeout| *timeout); formatter .debug_struct("AuthenticationSession") - .field("timeout", &self.shared.timeout) + .field("timeout", &timeout) .field("unlock_material", &"[REDACTED]") .finish() } @@ -203,7 +208,7 @@ impl AuthenticationSession // The lease is the only cache. This prevents a second lifetime // policy in SecretStore from retaining duplicate records. store: SecretStore::new(backend, SecretCachePolicy::Disabled, protections), - timeout, + timeout: Mutex::new(timeout), clock, operation: Mutex::new(()), state: Mutex::new(LeaseState { @@ -308,6 +313,23 @@ impl AuthenticationSession let _operation = self.shared.operation()?; self.shared.revoke(RevocationReason::Cancelled) } + + /// Apply a new shared inactivity timeout to this process and any active lease. + pub fn set_timeout(&self, timeout: AuthenticationTimeout) -> Result<(), AuthenticationError> { + let _operation = self.shared.operation()?; + self.shared.expire_if_needed()?; + let deadline = self + .shared + .clock + .now() + .checked_add(timeout.duration()) + .ok_or(AuthenticationError::ClockOverflow)?; + *self.shared.timeout()? = timeout; + if let Some(active) = self.shared.state()?.active.as_mut() { + active.deadline = deadline; + } + Ok(()) + } } /// Generation-bound secret access. Clones are revoked together. @@ -351,7 +373,7 @@ impl AuthenticationHandle { self.shared.expire_if_needed()?; let now = self.shared.clock.now(); let deadline = now - .checked_add(self.shared.timeout.duration()) + .checked_add(self.shared.timeout()?.duration()) .ok_or(AuthenticationError::ClockOverflow)?; self.shared .with_active(self.generation, |active| active.deadline = deadline) @@ -432,10 +454,18 @@ impl Shared { .map_err(|_| AuthenticationError::SecretStore(SecretStoreError::Unavailable)) } + fn timeout( + &self, + ) -> Result, AuthenticationError> { + self.timeout + .lock() + .map_err(|_| AuthenticationError::SecretStore(SecretStoreError::Unavailable)) + } + fn begin_lease(&self) -> Result { let now = self.clock.now(); let deadline = now - .checked_add(self.timeout.duration()) + .checked_add(self.timeout()?.duration()) .ok_or(AuthenticationError::ClockOverflow)?; let (generation, was_active) = { let mut state = self.state()?; diff --git a/crates/storage/src/config.rs b/crates/storage/src/config.rs index 6c49086..7ee3c7c 100644 --- a/crates/storage/src/config.rs +++ b/crates/storage/src/config.rs @@ -40,6 +40,7 @@ pub struct Config { clipboard_timeout: ClipboardTimeout, authentication_timeout: AuthenticationTimeout, biometric_unlock_enabled: bool, + mobile_appearance: MobileAppearance, mobile_tab: MobileTab, mobile_home_refreshed_at: Option, watch_shared_totp_entries: BTreeSet, @@ -60,6 +61,35 @@ pub struct ConfigSettings { authentication_timeout: Duration, } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum MobileAppearance { + #[default] + System, + Light, + Dark, +} + +impl MobileAppearance { + fn from_config(value: &str) -> Result { + match value { + "system" => Ok(Self::System), + "light" => Ok(Self::Light), + "dark" => Ok(Self::Dark), + _ => Err(ConfigError::InvalidField { + field: "ui.mobile_appearance", + }), + } + } + + const fn config_value(self) -> &'static str { + match self { + Self::System => "system", + Self::Light => "light", + Self::Dark => "dark", + } + } +} + impl ConfigSettings { pub fn vault(&self) -> &Path { &self.vault @@ -133,6 +163,10 @@ impl Config { self.biometric_unlock_enabled } + pub fn mobile_appearance(&self) -> MobileAppearance { + self.mobile_appearance + } + pub fn mobile_tab(&self) -> MobileTab { self.mobile_tab } @@ -280,6 +314,66 @@ impl Config { validate_config(self.source.clone(), document, raw)?.persist() } + pub fn update_authentication_timeout( + &self, + timeout: AuthenticationTimeout, + ) -> Result<(), ConfigError> { + let mut document = self.current_document()?; + 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" })?; + let seconds = + i64::try_from(timeout.duration().as_secs()).map_err(|_| ConfigError::InvalidField { + field: "security.inactivity_timeout_seconds", + })?; + security.insert( + "inactivity_timeout_seconds".to_owned(), + toml::Value::Integer(seconds), + ); + let raw = document + .clone() + .try_into::() + .map_err(|_| ConfigError::Malformed { + path: self.source.clone(), + })?; + validate_config(self.source.clone(), document, raw)?.persist() + } + + pub fn update_mobile_appearance( + &self, + appearance: MobileAppearance, + ) -> Result<(), ConfigError> { + let mut document = self.current_document()?; + let root = document + .as_table_mut() + .ok_or_else(|| ConfigError::Malformed { + path: self.source.clone(), + })?; + let ui = root + .entry("ui") + .or_insert_with(|| toml::Value::Table(toml::Table::new())) + .as_table_mut() + .ok_or(ConfigError::InvalidField { field: "ui" })?; + ui.insert( + "mobile_appearance".to_owned(), + toml::Value::String(appearance.config_value().to_owned()), + ); + let raw = document + .clone() + .try_into::() + .map_err(|_| ConfigError::Malformed { + path: self.source.clone(), + })?; + validate_config(self.source.clone(), document, raw)?.persist() + } + pub fn update_git_identity(&self, identity: &GitIdentity) -> Result<(), ConfigError> { let mut document = self.current_document()?; let root = document @@ -952,6 +1046,7 @@ struct RawSecurity { #[serde(deny_unknown_fields)] struct RawUi { selected_mobile_tab: Option, + mobile_appearance: Option, home_remote_refreshed_at_unix_seconds: Option, #[serde(default)] watch_shared_totp_entries: Vec, @@ -1032,6 +1127,13 @@ fn validate_config( field: "security.inactivity_timeout_seconds", })?; let biometric_unlock_enabled = raw.security.biometric_unlock_enabled.unwrap_or(false); + let mobile_appearance = raw + .ui + .mobile_appearance + .as_deref() + .map(MobileAppearance::from_config) + .transpose()? + .unwrap_or_default(); let mobile_tab = raw .ui .selected_mobile_tab @@ -1083,6 +1185,7 @@ fn validate_config( clipboard_timeout, authentication_timeout, biometric_unlock_enabled, + mobile_appearance, mobile_tab, mobile_home_refreshed_at, watch_shared_totp_entries, @@ -1270,6 +1373,7 @@ fn validate_known_fields(value: &toml::Value, source: &Path) -> Result<(), Confi "ui", &[ "selected_mobile_tab", + "mobile_appearance", "home_remote_refreshed_at_unix_seconds", "watch_shared_totp_entries", ], diff --git a/crates/storage/src/mobile_authentication.rs b/crates/storage/src/mobile_authentication.rs index b10baea..7c758be 100644 --- a/crates/storage/src/mobile_authentication.rs +++ b/crates/storage/src/mobile_authentication.rs @@ -1,12 +1,13 @@ //! Shared mobile authentication state; Swift only supplies input and presents results. -use std::{collections::BTreeMap, error::Error, fmt, sync::Mutex}; +use std::{collections::BTreeMap, error::Error, fmt, sync::Mutex, time::Duration}; use crate::{ authentication::{ - AuthenticationError, NativeAuthenticationHandle, NativeAuthenticationSession, + AuthenticationError, AuthenticationTimeout, NativeAuthenticationHandle, + NativeAuthenticationSession, }, - config::{Config, ConfigError}, + config::{Config, ConfigError, GitRemote, MobileAppearance}, crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider, SecretProviderError}, document::{DocumentError, EntryDocument, EntryDocumentService, EntryFieldId}, git::{AutomaticEntryCommitter, AutomaticTreeCommitter, GitError, GitIdentity}, @@ -27,7 +28,9 @@ use crate::{ repository::{ DirectoryPath, EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes, }, - secret_store::{SecretProtectionPolicy, SecretStoreError}, + secret_store::{ + NativeSecretStore, SecretCachePolicy, SecretProtectionPolicy, SecretStoreError, + }, write::{VaultWriter, WriteError}, }; @@ -129,6 +132,86 @@ pub struct MobileAuthenticationState { remaining_seconds: u64, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MobileWatchPreferenceState { + Unsupported, + NotPaired, + AppNotInstalled, + Ready, + Pending, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MobilePreferences { + repository_title: String, + repository_url: String, + server_title: String, + server_identity: String, + application_account: Option, + default_key_title: String, + default_key_fingerprint: String, + authentication_timeout_seconds: u64, + biometric_unlock_enabled: bool, + appearance: MobileAppearance, + watch_state: MobileWatchPreferenceState, + watch_title: String, + watch_detail: String, +} + +impl MobilePreferences { + pub fn repository_title(&self) -> &str { + &self.repository_title + } + + pub fn repository_url(&self) -> &str { + &self.repository_url + } + + pub fn server_title(&self) -> &str { + &self.server_title + } + + pub fn server_identity(&self) -> &str { + &self.server_identity + } + + pub fn application_account(&self) -> Option<&str> { + self.application_account.as_deref() + } + + pub fn default_key_title(&self) -> &str { + &self.default_key_title + } + + pub fn default_key_fingerprint(&self) -> &str { + &self.default_key_fingerprint + } + + pub fn authentication_timeout_seconds(&self) -> u64 { + self.authentication_timeout_seconds + } + + pub fn biometric_unlock_enabled(&self) -> bool { + self.biometric_unlock_enabled + } + + pub fn appearance(&self) -> MobileAppearance { + self.appearance + } + + pub fn watch_state(&self) -> MobileWatchPreferenceState { + self.watch_state + } + + pub fn watch_title(&self) -> &str { + &self.watch_title + } + + pub fn watch_detail(&self) -> &str { + &self.watch_detail + } +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct MobileEntryCopy { value: String, @@ -190,6 +273,8 @@ struct ActiveMobileLease { struct MobileAuthenticationStatus { biometric_unlock_enabled: bool, + authentication_timeout: AuthenticationTimeout, + appearance: MobileAppearance, git_identity: GitIdentity, active: Option, next_editor_id: u64, @@ -256,6 +341,8 @@ impl MobileAuthentication { Ok(Self { status: Mutex::new(MobileAuthenticationStatus { biometric_unlock_enabled: config.biometric_unlock_enabled(), + authentication_timeout: config.authentication_timeout(), + appearance: config.mobile_appearance(), git_identity: config.git_identity().clone(), active: None, next_editor_id: 0, @@ -430,6 +517,10 @@ impl MobileAuthentication { Ok(self.status()?.git_identity.clone()) } + pub fn mobile_appearance(&self) -> Result { + Ok(self.status()?.appearance) + } + pub fn set_git_identity( &self, name: String, @@ -449,6 +540,112 @@ impl MobileAuthentication { Ok(identity) } + pub fn preferences( + &self, + watch_supported: bool, + watch_paired: bool, + watch_app_installed: bool, + ) -> Result { + let remote = self + .config + .git_remotes() + .iter() + .find(|remote| remote.name().as_str() == "origin") + .or_else(|| self.config.git_remotes().first()) + .ok_or_else(|| config_detail("No HTTPS Git remote is configured."))?; + let handle = self + .keys + .resolve(self.config.default_key().as_str()) + .map_err(key_error)?; + let key = self + .keys + .infos() + .find(|key| key.fingerprint() == handle.fingerprint()) + .ok_or_else(|| config_detail("The default GPG key is unavailable."))?; + let status = self.status()?; + let selected = status.watch_shared_totp_entries.len(); + let (watch_state, watch_title, watch_detail) = + watch_preference(watch_supported, watch_paired, watch_app_installed, selected); + let authentication_timeout_seconds = status.authentication_timeout.duration().as_secs(); + let biometric_unlock_enabled = status.biometric_unlock_enabled; + let appearance = status.appearance; + drop(status); + let server_title = remote.url().host_str().unwrap_or("HTTPS server").to_owned(); + let repository_title = remote + .url() + .path_segments() + .and_then(Iterator::last) + .unwrap_or("Password Store") + .trim_end_matches(".git") + .to_owned(); + Ok(MobilePreferences { + repository_title, + repository_url: remote.url().to_string(), + server_title, + server_identity: remote.server_id().as_str().to_owned(), + application_account: application_account(remote)?, + default_key_title: key + .user_ids() + .first() + .cloned() + .unwrap_or_else(|| self.config.default_key().to_string()), + default_key_fingerprint: key.fingerprint().to_string(), + authentication_timeout_seconds, + biometric_unlock_enabled, + appearance, + watch_state, + watch_title, + watch_detail, + }) + } + + pub fn set_authentication_timeout( + &self, + seconds: u64, + ) -> Result<(), MobileAuthenticationError> { + let timeout = AuthenticationTimeout::new(Duration::from_secs(seconds)) + .map_err(|_| config_detail("Choose an inactivity timeout from 1 to 86400 seconds."))?; + let previous = self.status()?.authentication_timeout; + self.session + .set_timeout(timeout) + .map_err(MobileAuthenticationError::authentication)?; + if let Err(error) = self.config.update_authentication_timeout(timeout) { + let _ = self.session.set_timeout(previous); + return Err(config_error(error)); + } + self.status()?.authentication_timeout = timeout; + Ok(()) + } + + pub fn set_mobile_appearance( + &self, + appearance: MobileAppearance, + ) -> Result<(), MobileAuthenticationError> { + self.config + .update_mobile_appearance(appearance) + .map_err(config_error)?; + self.status()?.appearance = appearance; + Ok(()) + } + + pub fn remove_application_token(&self) -> Result<(), MobileAuthenticationError> { + let remote = self + .config + .git_remotes() + .iter() + .find(|remote| remote.name().as_str() == "origin") + .or_else(|| self.config.git_remotes().first()) + .ok_or_else(|| config_detail("No HTTPS Git remote is configured."))?; + let store = preference_secret_store()?; + store.unlock().map_err(preference_secret_error)?; + let result = store.delete_https_git_credential(remote.server_id(), remote.application_id()); + let lock_result = store.lock(); + if !matches!(result, Ok(()) | Err(SecretStoreError::Missing)) { + return Err(preference_secret_error(result.expect_err("checked error"))); + } + lock_result.map_err(preference_secret_error) + } + pub fn touch_user_activity(&self) -> Result<(), MobileAuthenticationError> { let status = self.status()?; let active = status.active.as_ref().ok_or_else(|| { @@ -1183,6 +1380,99 @@ fn config_error(error: ConfigError) -> MobileAuthenticationError { ) } +fn config_detail(detail: &str) -> MobileAuthenticationError { + MobileAuthenticationError::new( + MobileAuthenticationErrorKind::Configuration, + "Preference Is Unavailable", + detail, + ) +} + +fn preference_secret_store() -> Result { + NativeSecretStore::system( + SecretCachePolicy::Disabled, + SecretProtectionPolicy::device_unlocked(), + ) + .map_err(preference_secret_error) +} + +fn application_account(remote: &GitRemote) -> Result, MobileAuthenticationError> { + let store = preference_secret_store()?; + store.unlock().map_err(preference_secret_error)?; + let result = store.https_git_credential_account(remote.server_id(), remote.application_id()); + let lock_result = store.lock(); + let account = match result { + Ok(account) => Some(account), + Err(SecretStoreError::Missing) => None, + Err(error) => return Err(preference_secret_error(error)), + }; + lock_result + .map_err(preference_secret_error) + .map(|()| account) +} + +fn preference_secret_error(error: SecretStoreError) -> MobileAuthenticationError { + MobileAuthenticationError::new( + MobileAuthenticationErrorKind::SecureStorage, + "Application Token Is Unavailable", + match error { + SecretStoreError::Denied => "Access to protected token storage was denied.".to_owned(), + SecretStoreError::Cancelled => { + "Access to protected token storage was cancelled.".to_owned() + } + _ => "Protected application-token storage is unavailable.".to_owned(), + }, + ) +} + +fn watch_preference( + supported: bool, + paired: bool, + app_installed: bool, + selected: usize, +) -> (MobileWatchPreferenceState, String, String) { + if !supported { + return ( + MobileWatchPreferenceState::Unsupported, + "Unavailable".to_owned(), + "Apple Watch connectivity is unavailable on this device.".to_owned(), + ); + } + if !paired { + return ( + MobileWatchPreferenceState::NotPaired, + "Not Paired".to_owned(), + "Pair an Apple Watch in the Watch app to enable synchronization.".to_owned(), + ); + } + if !app_installed { + return ( + MobileWatchPreferenceState::AppNotInstalled, + "Watch App Not Installed".to_owned(), + "Install the IronStorage companion on the paired Apple Watch.".to_owned(), + ); + } + if selected == 0 { + return ( + MobileWatchPreferenceState::Ready, + "Ready".to_owned(), + "No TOTP codes are selected for Apple Watch.".to_owned(), + ); + } + ( + MobileWatchPreferenceState::Pending, + "Synchronization Pending".to_owned(), + format!( + "{selected} selected TOTP {} pending Apple Watch synchronization.", + if selected == 1 { + "code is" + } else { + "codes are" + } + ), + ) +} + fn entry_error(error: RepositoryError) -> MobileAuthenticationError { MobileAuthenticationError::new( MobileAuthenticationErrorKind::Entry, @@ -1293,7 +1583,8 @@ fn entry_detail(title: &str, error: impl fmt::Display) -> MobileAuthenticationEr #[cfg(test)] mod tests { use super::{ - MobileRepositoryOperation, MobileRepositoryOperationError, repository_operation_conflict, + MobileRepositoryOperation, MobileRepositoryOperationError, MobileWatchPreferenceState, + repository_operation_conflict, watch_preference, }; #[test] @@ -1319,4 +1610,19 @@ mod tests { Some(MobileRepositoryOperationError::Busy) ); } + + #[test] + fn watch_preferences_render_platform_and_selection_state() { + assert_eq!( + watch_preference(true, false, false, 2).0, + MobileWatchPreferenceState::NotPaired + ); + assert_eq!( + watch_preference(true, true, true, 0).0, + MobileWatchPreferenceState::Ready + ); + let pending = watch_preference(true, true, true, 2); + assert_eq!(pending.0, MobileWatchPreferenceState::Pending); + assert!(pending.2.contains("2 selected TOTP codes")); + } } diff --git a/crates/storage/src/secret_store.rs b/crates/storage/src/secret_store.rs index d1b3b4b..08a377a 100644 --- a/crates/storage/src/secret_store.rs +++ b/crates/storage/src/secret_store.rs @@ -557,6 +557,29 @@ impl SecretStore { Ok(()) } + /// Return only the non-secret account identity for one configured Git credential. + pub fn https_git_credential_account( + &self, + server: &ServerId, + application: &ApplicationId, + ) -> Result { + self.retrieve_git_record(server, application)? + .reference + .account() + .map(str::to_owned) + .ok_or(SecretStoreError::Corrupted) + } + + /// Remove the credential selected by its configured server/application identity. + pub fn delete_https_git_credential( + &self, + server: &ServerId, + application: &ApplicationId, + ) -> Result<(), SecretStoreError> { + let record = self.retrieve_git_record(server, application)?; + self.delete(&record.reference) + } + pub fn delete(&self, reference: &SecretReference) -> Result<(), SecretStoreError> { let locator = reference.locator(); let mut state = self.unlocked_state()?; diff --git a/crates/storage/tests/authentication_leases.rs b/crates/storage/tests/authentication_leases.rs index 80236d8..0a6286a 100644 --- a/crates/storage/tests/authentication_leases.rs +++ b/crates/storage/tests/authentication_leases.rs @@ -249,6 +249,30 @@ fn user_activity_extends_the_lease_but_secret_access_and_timer_polling_do_not() Ok(()) } +#[test] +fn timeout_updates_apply_to_the_active_lease_and_later_activity() -> TestResult { + let fixture = FixtureSet::load()?; + let keys = KeyStore::load(fixture.path("keys"))?; + let alice = fixture_key(&fixture, &keys, "alice")?; + let backend = MemoryBackend::default(); + provision_passphrase( + backend.clone(), + &alice, + fixture.key("alice")?.passphrase.as_bytes(), + )?; + let clock = ManualClock::default(); + let session = session(backend, clock.clone(), 120)?; + let handle = session.authenticate(&alice)?; + + clock.advance(Duration::from_secs(20)); + session.set_timeout(AuthenticationTimeout::new(Duration::from_secs(30))?)?; + assert_eq!(handle.remaining_time()?, Duration::from_secs(30)); + clock.advance(Duration::from_secs(10)); + handle.touch_user_activity()?; + assert_eq!(handle.remaining_time()?, Duration::from_secs(30)); + Ok(()) +} + #[test] fn manual_lock_cancellation_and_expiry_revoke_all_existing_handles() -> TestResult { let fixture = FixtureSet::load()?; diff --git a/crates/storage/tests/config_contract.rs b/crates/storage/tests/config_contract.rs index 30d3d7a..8560117 100644 --- a/crates/storage/tests/config_contract.rs +++ b/crates/storage/tests/config_contract.rs @@ -4,8 +4,10 @@ use std::{collections::BTreeSet, error::Error, ffi::OsStr, fs, path::Path, time: use ironstorage::presentation::DEFAULT_CLIPBOARD_TIMEOUT; use ironstorage::{ - authentication::{DEFAULT_AUTHENTICATION_TIMEOUT, MAX_AUTHENTICATION_TIMEOUT}, - config::{ConfigError, ConfigLoader, EditorSource}, + authentication::{ + AuthenticationTimeout, DEFAULT_AUTHENTICATION_TIMEOUT, MAX_AUTHENTICATION_TIMEOUT, + }, + config::{ConfigError, ConfigLoader, EditorSource, MobileAppearance}, desktop::DesktopStorage, git::GitIdentity, mobile::MobileTab, @@ -299,6 +301,29 @@ fn biometric_preference_is_secret_free_and_defaults_to_disabled() -> TestResult Ok(()) } +#[test] +fn mobile_timeout_and_appearance_share_the_secret_free_configuration() -> 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_eq!(config.mobile_appearance(), MobileAppearance::System); + + config.update_mobile_appearance(MobileAppearance::Dark)?; + config.update_authentication_timeout(AuthenticationTimeout::new(Duration::from_secs(300))?)?; + let reloaded = fixture.loader().load(Some(&fixture.explicit_path()))?; + assert_eq!(reloaded.mobile_appearance(), MobileAppearance::Dark); + assert_eq!( + reloaded.authentication_timeout().duration(), + Duration::from_secs(300) + ); + let contents = fs::read_to_string(fixture.explicit_path())?; + assert!(contents.contains("mobile_appearance = \"dark\"")); + assert!(contents.contains("inactivity_timeout_seconds = 300")); + assert!(!contents.to_ascii_lowercase().contains("token =")); + Ok(()) +} + #[test] fn desktop_vault_switch_preserves_and_reloads_the_shared_configuration() -> TestResult { let fixture = ConfigurationFixture::new()?; diff --git a/crates/storage/tests/secret_store.rs b/crates/storage/tests/secret_store.rs index d66d6a7..e9b791f 100644 --- a/crates/storage/tests/secret_store.rs +++ b/crates/storage/tests/secret_store.rs @@ -11,7 +11,7 @@ use std::{ }; use ironstorage::{ - config::ConfigLoader, + config::{ConfigLoader, GitRemote}, crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider as _, SecretProviderError}, git::{GitCredentialProvider as _, GitError}, repository::{EncryptedEntry, SecretBytes}, @@ -325,6 +325,40 @@ fn bounded_cache_is_cleared_by_lock_and_never_aliases_git_accounts() -> TestResu Ok(()) } +#[test] +fn git_account_status_and_removal_never_expose_the_token() -> TestResult { + let backend = MemoryBackend::default(); + let store = SecretStore::new( + backend, + SecretCachePolicy::Disabled, + SecretProtectionPolicy::device_unlocked(), + ); + let remote = GitRemote::https( + "origin", + "https://git.example.test/alice/store.git", + "personal-git", + "ironstorage-mobile", + )?; + store.unlock()?; + store.store_https_git_credential( + remote.server_id(), + remote.application_id(), + "alice", + SecretBytes::new(b"private-token".to_vec()), + )?; + assert_eq!( + store.https_git_credential_account(remote.server_id(), remote.application_id())?, + "alice" + ); + store.delete_https_git_credential(remote.server_id(), remote.application_id())?; + assert!(matches!( + store.https_git_credential_account(remote.server_id(), remote.application_id()), + Err(SecretStoreError::Missing) + )); + assert!(!format!("{store:?}").contains("private-token")); + Ok(()) +} + #[test] fn one_unlocked_provider_supplies_openpgp_and_https_git_secrets() -> TestResult { let fixture = FixtureSet::load()?; diff --git a/docs/configuration.md b/docs/configuration.md index c5cea61..b131faf 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -32,6 +32,11 @@ clipboard_timeout_seconds = 45 # Optional; interactive frontends default to a two-minute inactivity lease. [security] inactivity_timeout_seconds = 120 +biometric_unlock_enabled = false + +# Optional iPhone appearance: "system", "light", or "dark". +[ui] +mobile_appearance = "system" [git] user_name = "Alice Example" @@ -76,6 +81,11 @@ from 1 second through 24 hours. The storage crate owns deadline calculation and relock; frontends report only genuine input events as user activity. Repaints, timers, background refresh, and Git work never extend the lease. +`security.biometric_unlock_enabled` records only whether biometric unlock is +enabled; the GPG passphrase remains in protected system storage. +`ui.mobile_appearance` is shared, secret-free iPhone presentation state and +defaults to the system appearance. + Passwords, passphrases, tokens, credentials, private keys, and other secret values are forbidden in TOML. Unknown fields are rejected. Parse errors never echo the source line or value, so an accidentally supplied secret is not