From a055c93b86d70e2ce00067e0dc9ca13801fdd14e Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sun, 16 Aug 2026 13:47:31 +0200 Subject: [PATCH] Synchronize selected TOTP entries to Apple Watch (#55) --- apple/Generated/ironstorage_apple.swift | 221 ++++- apple/Generated/ironstorage_appleFFI.h | 55 ++ apple/Sources/App/IronStorageApp.swift | 237 ++++- apple/Sources/Watch/IronStorageWatchApp.swift | 47 + crates/apple/src/lib.rs | 89 ++ crates/storage/src/lib.rs | 1 + crates/storage/src/mobile_authentication.rs | 217 ++++- crates/storage/src/mobile_totp.rs | 78 +- crates/storage/src/mobile_watch.rs | 868 ++++++++++++++++++ crates/storage/src/otp.rs | 4 + crates/storage/tests/mobile_totp.rs | 2 +- crates/storage/tests/mobile_watch.rs | 167 ++++ 12 files changed, 1888 insertions(+), 98 deletions(-) create mode 100644 crates/storage/src/mobile_watch.rs create mode 100644 crates/storage/tests/mobile_watch.rs diff --git a/apple/Generated/ironstorage_apple.swift b/apple/Generated/ironstorage_apple.swift index db7eb77..0a5d22a 100644 --- a/apple/Generated/ironstorage_apple.swift +++ b/apple/Generated/ironstorage_apple.swift @@ -603,6 +603,8 @@ fileprivate struct FfiConverterData: FfiConverterRustBuffer { public protocol MobileAuthenticationProtocol: AnyObject, Sendable { + func acknowledgeWatchSnapshot(receipt: Data) throws -> MobileWatchSnapshotStatus + func addEntryEditorField(editor: UInt64, kind: MobileEntryEditorFieldKind, name: String?, value: String) throws -> MobileEntryEditorPage func beginCreateEntry(directory: String, name: String, passphrase: String?) throws -> MobileEntryEditorSession @@ -623,6 +625,8 @@ public protocol MobileAuthenticationProtocol: AnyObject, Sendable { func entryPage(path: String, unixSeconds: UInt64) throws -> MobileEntryPresentation + func failWatchSnapshot(revision: UInt64, detail: String) throws -> MobileWatchSnapshotStatus + func generateEntryEditorPassword(editor: UInt64, length: UInt32?, noSymbols: Bool) throws -> MobileEntryEditorPage func gitIdentity() throws -> MobileGitIdentity @@ -637,6 +641,8 @@ public protocol MobileAuthenticationProtocol: AnyObject, Sendable { func prepareEntryMutation(path: String, action: MobileMutationAction) throws -> MobileMutationPlan + func prepareWatchSnapshot(platformPairingIdentity: String) throws -> MobileWatchSnapshotTransfer + func removeApplicationToken() throws func removeEntryEditorField(editor: UInt64, field: UInt64) throws -> MobileEntryEditorPage @@ -661,6 +667,8 @@ public protocol MobileAuthenticationProtocol: AnyObject, Sendable { func setTotpWatchShared(path: String, shared: Bool, unixSeconds: UInt64) throws -> MobileTotpDetail + func setWatchSnapshotUnavailable(unpaired: Bool, detail: String) throws -> MobileWatchSnapshotStatus + func state() throws -> MobileAuthenticationState func totpDetail(path: String, unixSeconds: UInt64) throws -> MobileTotpDetail @@ -675,6 +683,8 @@ public protocol MobileAuthenticationProtocol: AnyObject, Sendable { func updateEntryEditor(editor: UInt64, fields: [MobileEntryEditorInput]) throws -> MobileEntryEditorPage + func watchSnapshotStatus() throws -> MobileWatchSnapshotStatus + } open class MobileAuthentication: MobileAuthenticationProtocol, @unchecked Sendable { fileprivate let handle: UInt64 @@ -729,6 +739,16 @@ open class MobileAuthentication: MobileAuthenticationProtocol, @unchecked Sendab +open func acknowledgeWatchSnapshot(receipt: Data)throws -> MobileWatchSnapshotStatus { + return try FfiConverterTypeMobileWatchSnapshotStatus_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobileauthentication_acknowledge_watch_snapshot( + self.uniffiCloneHandle(), + FfiConverterData.lower(receipt),uniffiCallStatus + ) +}) +} + open func addEntryEditorField(editor: UInt64, kind: MobileEntryEditorFieldKind, name: String?, value: String)throws -> MobileEntryEditorPage { return try FfiConverterTypeMobileEntryEditorPage_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { uniffiCallStatus in @@ -833,6 +853,17 @@ open func entryPage(path: String, unixSeconds: UInt64)throws -> MobileEntryPres }) } +open func failWatchSnapshot(revision: UInt64, detail: String)throws -> MobileWatchSnapshotStatus { + return try FfiConverterTypeMobileWatchSnapshotStatus_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobileauthentication_fail_watch_snapshot( + self.uniffiCloneHandle(), + FfiConverterUInt64.lower(revision), + FfiConverterString.lower(detail),uniffiCallStatus + ) +}) +} + open func generateEntryEditorPassword(editor: UInt64, length: UInt32?, noSymbols: Bool)throws -> MobileEntryEditorPage { return try FfiConverterTypeMobileEntryEditorPage_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { uniffiCallStatus in @@ -904,6 +935,16 @@ open func prepareEntryMutation(path: String, action: MobileMutationAction)throws }) } +open func prepareWatchSnapshot(platformPairingIdentity: String)throws -> MobileWatchSnapshotTransfer { + return try FfiConverterTypeMobileWatchSnapshotTransfer_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobileauthentication_prepare_watch_snapshot( + self.uniffiCloneHandle(), + FfiConverterString.lower(platformPairingIdentity),uniffiCallStatus + ) +}) +} + open func removeApplicationToken()throws {try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { uniffiCallStatus in uniffi_ironstorage_apple_fn_method_mobileauthentication_remove_application_token( @@ -1030,6 +1071,17 @@ open func setTotpWatchShared(path: String, shared: Bool, unixSeconds: UInt64)thr }) } +open func setWatchSnapshotUnavailable(unpaired: Bool, detail: String)throws -> MobileWatchSnapshotStatus { + return try FfiConverterTypeMobileWatchSnapshotStatus_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobileauthentication_set_watch_snapshot_unavailable( + self.uniffiCloneHandle(), + FfiConverterBool.lower(unpaired), + FfiConverterString.lower(detail),uniffiCallStatus + ) +}) +} + open func state()throws -> MobileAuthenticationState { return try FfiConverterTypeMobileAuthenticationState_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { uniffiCallStatus in @@ -1100,6 +1152,15 @@ open func updateEntryEditor(editor: UInt64, fields: [MobileEntryEditorInput])thr }) } +open func watchSnapshotStatus()throws -> MobileWatchSnapshotStatus { + return try FfiConverterTypeMobileWatchSnapshotStatus_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) { + uniffiCallStatus in + uniffi_ironstorage_apple_fn_method_mobileauthentication_watch_snapshot_status( + self.uniffiCloneHandle(),uniffiCallStatus + ) +}) +} + } @@ -4453,12 +4514,14 @@ public func FfiConverterTypeMobileTotpRow_lower(_ value: MobileTotpRow) -> RustB public struct MobileWatchSnapshotStatus: Equatable, Hashable { public var state: MobileWatchSnapshotState + public var revision: UInt64? public var detail: String // Default memberwise initializers are never public by default, so we // declare one manually. - public init(state: MobileWatchSnapshotState, detail: String) { + public init(state: MobileWatchSnapshotState, revision: UInt64?, detail: String) { self.state = state + self.revision = revision self.detail = detail } @@ -4479,12 +4542,14 @@ public struct FfiConverterTypeMobileWatchSnapshotStatus: FfiConverterRustBuffer return try MobileWatchSnapshotStatus( state: FfiConverterTypeMobileWatchSnapshotState.read(from: &buf), + revision: FfiConverterOptionUInt64.read(from: &buf), detail: FfiConverterString.read(from: &buf) ) } public static func write(_ value: MobileWatchSnapshotStatus, into buf: inout [UInt8]) { FfiConverterTypeMobileWatchSnapshotState.write(value.state, into: &buf) + FfiConverterOptionUInt64.write(value.revision, into: &buf) FfiConverterString.write(value.detail, into: &buf) } } @@ -4505,6 +4570,68 @@ public func FfiConverterTypeMobileWatchSnapshotStatus_lower(_ value: MobileWatch } +public struct MobileWatchSnapshotTransfer: Equatable, Hashable { + public var revision: UInt64 + public var selectedEntries: UInt32 + public var snapshot: Data + public var deliveredReceipt: Data + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(revision: UInt64, selectedEntries: UInt32, snapshot: Data, deliveredReceipt: Data) { + self.revision = revision + self.selectedEntries = selectedEntries + self.snapshot = snapshot + self.deliveredReceipt = deliveredReceipt + } + + + + +} + +#if compiler(>=6) +extension MobileWatchSnapshotTransfer: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMobileWatchSnapshotTransfer: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileWatchSnapshotTransfer { + return + try MobileWatchSnapshotTransfer( + revision: FfiConverterUInt64.read(from: &buf), + selectedEntries: FfiConverterUInt32.read(from: &buf), + snapshot: FfiConverterData.read(from: &buf), + deliveredReceipt: FfiConverterData.read(from: &buf) + ) + } + + public static func write(_ value: MobileWatchSnapshotTransfer, into buf: inout [UInt8]) { + FfiConverterUInt64.write(value.revision, into: &buf) + FfiConverterUInt32.write(value.selectedEntries, into: &buf) + FfiConverterData.write(value.snapshot, into: &buf) + FfiConverterData.write(value.deliveredReceipt, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileWatchSnapshotTransfer_lift(_ buf: RustBuffer) throws -> MobileWatchSnapshotTransfer { + return try FfiConverterTypeMobileWatchSnapshotTransfer.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMobileWatchSnapshotTransfer_lower(_ value: MobileWatchSnapshotTransfer) -> RustBuffer { + return FfiConverterTypeMobileWatchSnapshotTransfer.lower(value) +} + + public enum MobileAppearance: Equatable, Hashable { @@ -6618,6 +6745,9 @@ public enum MobileWatchPreferenceState: Equatable, Hashable { case appNotInstalled case ready case pending + case delivered + case current + case failed @@ -6649,6 +6779,12 @@ public struct FfiConverterTypeMobileWatchPreferenceState: FfiConverterRustBuffer case 5: return .pending + case 6: return .delivered + + case 7: return .current + + case 8: return .failed + default: throw UniffiInternalError.unexpectedEnumCase } } @@ -6676,6 +6812,18 @@ public struct FfiConverterTypeMobileWatchPreferenceState: FfiConverterRustBuffer case .pending: writeInt(&buf, Int32(5)) + + case .delivered: + writeInt(&buf, Int32(6)) + + + case .current: + writeInt(&buf, Int32(7)) + + + case .failed: + writeInt(&buf, Int32(8)) + } } } @@ -6701,7 +6849,11 @@ public func FfiConverterTypeMobileWatchPreferenceState_lower(_ value: MobileWatc public enum MobileWatchSnapshotState: Equatable, Hashable { case unavailable + case unpaired case pending + case delivered + case current + case failed @@ -6725,7 +6877,15 @@ public struct FfiConverterTypeMobileWatchSnapshotState: FfiConverterRustBuffer { case 1: return .unavailable - case 2: return .pending + case 2: return .unpaired + + case 3: return .pending + + case 4: return .delivered + + case 5: return .current + + case 6: return .failed default: throw UniffiInternalError.unexpectedEnumCase } @@ -6739,9 +6899,25 @@ public struct FfiConverterTypeMobileWatchSnapshotState: FfiConverterRustBuffer { writeInt(&buf, Int32(1)) - case .pending: + case .unpaired: writeInt(&buf, Int32(2)) + + case .pending: + writeInt(&buf, Int32(3)) + + + case .delivered: + writeInt(&buf, Int32(4)) + + + case .current: + writeInt(&buf, Int32(5)) + + + case .failed: + writeInt(&buf, Int32(6)) + } } } @@ -6786,6 +6962,30 @@ fileprivate struct FfiConverterOptionUInt32: FfiConverterRustBuffer { } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionUInt64: FfiConverterRustBuffer { + typealias SwiftType = UInt64? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterUInt64.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterUInt64.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -7497,6 +7697,9 @@ private let initializationResult: InitializationResult = { if (uniffi_ironstorage_apple_checksum_func_set_selected_mobile_tab() != 65280) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_acknowledge_watch_snapshot() != 12885) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_add_entry_editor_field() != 58790) { return InitializationResult.apiChecksumMismatch } @@ -7527,6 +7730,9 @@ private let initializationResult: InitializationResult = { if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_entry_page() != 9073) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_fail_watch_snapshot() != 56313) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_generate_entry_editor_password() != 34289) { return InitializationResult.apiChecksumMismatch } @@ -7548,6 +7754,9 @@ private let initializationResult: InitializationResult = { if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_prepare_entry_mutation() != 6234) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_prepare_watch_snapshot() != 14176) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_remove_application_token() != 11452) { return InitializationResult.apiChecksumMismatch } @@ -7584,6 +7793,9 @@ private let initializationResult: InitializationResult = { if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_totp_watch_shared() != 57472) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_watch_snapshot_unavailable() != 49094) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_state() != 60826) { return InitializationResult.apiChecksumMismatch } @@ -7605,6 +7817,9 @@ private let initializationResult: InitializationResult = { if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_update_entry_editor() != 30139) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ironstorage_apple_checksum_method_mobileauthentication_watch_snapshot_status() != 17344) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_cached() != 32436) { return InitializationResult.apiChecksumMismatch } diff --git a/apple/Generated/ironstorage_appleFFI.h b/apple/Generated/ironstorage_appleFFI.h index 3b32b2b..74d168a 100644 --- a/apple/Generated/ironstorage_appleFFI.h +++ b/apple/Generated/ironstorage_appleFFI.h @@ -253,6 +253,11 @@ uint64_t uniffi_ironstorage_apple_fn_clone_mobileauthentication(uint64_t handle, void uniffi_ironstorage_apple_fn_free_mobileauthentication(uint64_t handle, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_ACKNOWLEDGE_WATCH_SNAPSHOT +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_ACKNOWLEDGE_WATCH_SNAPSHOT +RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_acknowledge_watch_snapshot(uint64_t ptr, RustBuffer receipt, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_ADD_ENTRY_EDITOR_FIELD #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_ADD_ENTRY_EDITOR_FIELD RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_add_entry_editor_field(uint64_t ptr, uint64_t editor, RustBuffer kind, RustBuffer name, RustBuffer value, RustCallStatus *_Nonnull out_status @@ -303,6 +308,11 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_entry_editor( RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_entry_page(uint64_t ptr, RustBuffer path, uint64_t unix_seconds, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_FAIL_WATCH_SNAPSHOT +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_FAIL_WATCH_SNAPSHOT +RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_fail_watch_snapshot(uint64_t ptr, uint64_t revision, RustBuffer detail, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_GENERATE_ENTRY_EDITOR_PASSWORD #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_GENERATE_ENTRY_EDITOR_PASSWORD RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_generate_entry_editor_password(uint64_t ptr, uint64_t editor, RustBuffer length, int8_t no_symbols, RustCallStatus *_Nonnull out_status @@ -338,6 +348,11 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_preferences(u 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_PREPARE_WATCH_SNAPSHOT +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_PREPARE_WATCH_SNAPSHOT +RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_prepare_watch_snapshot(uint64_t ptr, RustBuffer platform_pairing_identity, 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 @@ -398,6 +413,11 @@ void uniffi_ironstorage_apple_fn_method_mobileauthentication_set_mobile_appearan 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 ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_WATCH_SNAPSHOT_UNAVAILABLE +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_SET_WATCH_SNAPSHOT_UNAVAILABLE +RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_set_watch_snapshot_unavailable(uint64_t ptr, int8_t unpaired, RustBuffer detail, 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 @@ -433,6 +453,11 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_unlock_totp(u RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_update_entry_editor(uint64_t ptr, uint64_t editor, RustBuffer fields, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_WATCH_SNAPSHOT_STATUS +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEAUTHENTICATION_WATCH_SNAPSHOT_STATUS +RustBuffer uniffi_ironstorage_apple_fn_method_mobileauthentication_watch_snapshot_status(uint64_t ptr, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEHOMEOPERATION #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEHOMEOPERATION uint64_t uniffi_ironstorage_apple_fn_clone_mobilehomeoperation(uint64_t handle, RustCallStatus *_Nonnull out_status @@ -973,6 +998,12 @@ uint16_t uniffi_ironstorage_apple_checksum_func_replace_configured_mobile_applic #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_FUNC_SET_SELECTED_MOBILE_TAB uint16_t uniffi_ironstorage_apple_checksum_func_set_selected_mobile_tab(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_ACKNOWLEDGE_WATCH_SNAPSHOT +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_ACKNOWLEDGE_WATCH_SNAPSHOT +uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_acknowledge_watch_snapshot(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_ADD_ENTRY_EDITOR_FIELD @@ -1033,6 +1064,12 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_entry_edi #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_ENTRY_PAGE uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_entry_page(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_FAIL_WATCH_SNAPSHOT +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_FAIL_WATCH_SNAPSHOT +uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_fail_watch_snapshot(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_GENERATE_ENTRY_EDITOR_PASSWORD @@ -1075,6 +1112,12 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_preferenc #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_PREPARE_WATCH_SNAPSHOT +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PREPARE_WATCH_SNAPSHOT +uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_prepare_watch_snapshot(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_REMOVE_APPLICATION_TOKEN @@ -1147,6 +1190,12 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_mobil #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_TOTP_WATCH_SHARED uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_totp_watch_shared(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_WATCH_SNAPSHOT_UNAVAILABLE +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_WATCH_SNAPSHOT_UNAVAILABLE +uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_set_watch_snapshot_unavailable(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_STATE @@ -1189,6 +1238,12 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_unlock_to #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_UPDATE_ENTRY_EDITOR uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_update_entry_editor(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_WATCH_SNAPSHOT_STATUS +#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_WATCH_SNAPSHOT_STATUS +uint16_t uniffi_ironstorage_apple_checksum_method_mobileauthentication_watch_snapshot_status(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_CACHED diff --git a/apple/Sources/App/IronStorageApp.swift b/apple/Sources/App/IronStorageApp.swift index 7604f51..e470732 100644 --- a/apple/Sources/App/IronStorageApp.swift +++ b/apple/Sources/App/IronStorageApp.swift @@ -14,11 +14,161 @@ extension Notification.Name { static let ironStorageWatchSnapshotDidChange = Notification.Name( "de.rfc1437.ironstorage.watch-snapshot-did-change" ) + static let ironStorageWatchStatusDidChange = Notification.Name( + "de.rfc1437.ironstorage.watch-status-did-change" + ) static let ironStorageKeyMaterialDidChange = Notification.Name( "de.rfc1437.ironstorage.key-material-did-change" ) } +@MainActor +private final class WatchSnapshotCoordinator: NSObject, WCSessionDelegate { + private static let snapshotKey = "de.rfc1437.ironstorage.watch.snapshot" + private static let deliveredReceiptKey = "de.rfc1437.ironstorage.watch.delivered" + + private let authentication: MobileAuthentication? + private var session: WCSession? + private var syncTask: Task? + private var generation = 0 + + var isSupported: Bool { WCSession.isSupported() } + var isPaired: Bool { session?.isPaired ?? false } + var isWatchAppInstalled: Bool { session?.isWatchAppInstalled ?? false } + + init(authentication: MobileAuthentication?) { + self.authentication = authentication + super.init() + for name in [ + Notification.Name.ironStorageWatchSnapshotDidChange, + .ironStorageLocalStoreDidChange, + .ironStorageAuthenticationDidChange, + ] { + NotificationCenter.default.addObserver( + self, + selector: #selector(synchronize), + name: name, + object: nil + ) + } + guard WCSession.isSupported() else { + recordUnavailable(unpaired: false, detail: "Apple Watch connectivity is unavailable on this device.") + return + } + let session = WCSession.default + self.session = session + session.delegate = self + session.activate() + } + + deinit { + syncTask?.cancel() + session?.delegate = nil + NotificationCenter.default.removeObserver(self) + } + + @objc func synchronize() { + generation += 1 + syncTask?.cancel() + guard let authentication else { return } + guard let session, session.activationState == .activated else { return } + guard session.isPaired else { + try? session.updateApplicationContext([:]) + recordUnavailable(unpaired: true, detail: "Pair an Apple Watch to synchronize selected TOTP entries.") + return + } + guard session.isWatchAppInstalled, let directory = session.watchDirectoryURL else { + try? session.updateApplicationContext([:]) + recordUnavailable(unpaired: false, detail: "Install IronStorage on the paired Apple Watch.") + return + } + let pairingIdentity = directory.lastPathComponent + let current = generation + syncTask = Task { [weak self] in + let result = await Task.detached(priority: .utility) { + Result { try authentication.prepareWatchSnapshot(platformPairingIdentity: pairingIdentity) } + }.value + guard !Task.isCancelled, let self, current == generation else { return } + switch result { + case var .success(transfer): + var snapshot = Data(transfer.snapshot) + defer { + snapshot.resetBytes(in: 0..? private var authenticationMonitor: Task? @@ -83,6 +234,7 @@ private final class AppContext: NSObject, UITabBarControllerDelegate { } func makeRootController() -> UIViewController { + watchSnapshot.synchronize() let shell = mobileShellFixture(state: .loading) navigationControllers = shell.pages.map { page in let root: UIViewController = switch page.tab { @@ -93,7 +245,11 @@ private final class AppContext: NSObject, UITabBarControllerDelegate { case .totp: TotpListViewController(shellPage: page, authentication: authentication) case .preferences: - PreferencesViewController(page: page, authentication: authentication) + PreferencesViewController( + page: page, + authentication: authentication, + watchSnapshot: watchSnapshot + ) case .home: ShellViewController(page: page, authentication: authentication) } @@ -852,9 +1008,7 @@ private final class ShellViewController: UITableViewController, MobileTabRoot { } @MainActor -private final class PreferencesViewController: UITableViewController, MobileTabRoot, - WCSessionDelegate -{ +private final class PreferencesViewController: UITableViewController, MobileTabRoot { fileprivate let shellTab = MobileTab.preferences private var page: MobilePage private let authentication: MobileAuthentication? @@ -862,21 +1016,20 @@ private final class PreferencesViewController: UITableViewController, MobileTabR private var state: MobileAuthenticationState? private var preferences: MobilePreferences? private var gitIdentity: MobileGitIdentity? - private var watchSession: WCSession? + private let watchSnapshot: WatchSnapshotCoordinator private var preferenceTask: Task? private var loadTask: Task? private var loadGeneration = 0 - init(page: MobilePage, authentication: MobileAuthentication?) { + init( + page: MobilePage, + authentication: MobileAuthentication?, + watchSnapshot: WatchSnapshotCoordinator + ) { self.page = page self.authentication = authentication + self.watchSnapshot = watchSnapshot 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( @@ -891,6 +1044,12 @@ private final class PreferencesViewController: UITableViewController, MobileTabR name: .ironStorageAuthenticationDidChange, object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(authenticationDidChange), + name: .ironStorageWatchStatusDidChange, + object: nil + ) } @available(*, unavailable) @@ -899,7 +1058,6 @@ private final class PreferencesViewController: UITableViewController, MobileTabR } deinit { - watchSession?.delegate = nil preferenceTask?.cancel() loadTask?.cancel() NotificationCenter.default.removeObserver(self) @@ -1380,8 +1538,19 @@ private final class PreferencesViewController: UITableViewController, MobileTabR private func openWatchPreference() { switch preferences?.watchState { - case .ready, .pending: + case .ready, .pending, .delivered, .current: selectTotpTab() + case .failed: + let alert = UIAlertController( + title: "Apple Watch Synchronization Failed", + message: preferences?.watchDetail ?? "The latest snapshot could not be synchronized.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + alert.addAction(UIAlertAction(title: "Retry", style: .default) { [weak self] _ in + self?.watchSnapshot.synchronize() + }) + present(alert, animated: true) case .notPaired: presentWatchGuidance( title: "Pair an Apple Watch", @@ -1429,6 +1598,9 @@ private final class PreferencesViewController: UITableViewController, MobileTabR switch state { case .ready: "applewatch.radiowaves.left.and.right" case .pending: "arrow.triangle.2.circlepath" + case .delivered: "arrow.down.circle" + case .current: "checkmark.circle.fill" + case .failed: "exclamationmark.triangle" case .notPaired, .appNotInstalled: "applewatch.slash" default: "applewatch" } @@ -1480,9 +1652,9 @@ private final class PreferencesViewController: UITableViewController, MobileTabR state = try? authentication?.state() gitIdentity = try? authentication?.gitIdentity() preferences = try? authentication?.preferences( - watchSupported: WCSession.isSupported(), - watchPaired: watchSession?.isPaired ?? false, - watchAppInstalled: watchSession?.isWatchAppInstalled ?? false + watchSupported: watchSnapshot.isSupported, + watchPaired: watchSnapshot.isPaired, + watchAppInstalled: watchSnapshot.isWatchAppInstalled ) if let appearance = preferences?.appearance { tabBarController?.overrideUserInterfaceStyle = switch appearance { @@ -1494,23 +1666,6 @@ private final class PreferencesViewController: UITableViewController, MobileTabR 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 @@ -2220,6 +2375,12 @@ private final class TotpListViewController: UITableViewController, MobileTabRoot name: .ironStorageWatchSnapshotDidChange, object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(localStoreDidChange), + name: .ironStorageWatchStatusDidChange, + object: nil + ) refreshState() } @@ -2691,8 +2852,6 @@ private final class TotpListViewController: UITableViewController, MobileTabRoot object: authentication ) refreshState() - } else { - showUnavailable(title: failure.title, detail: failure.detail, image: "exclamationmark.triangle") } presentAuthenticationFailure(failure) } @@ -2870,6 +3029,12 @@ private final class TotpDetailViewController: UITableViewController { name: .ironStorageLocalStoreDidChange, object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(entryDidChange), + name: .ironStorageWatchStatusDidChange, + object: nil + ) apply(detail) startTimer() } diff --git a/apple/Sources/Watch/IronStorageWatchApp.swift b/apple/Sources/Watch/IronStorageWatchApp.swift index 880a722..0cda696 100644 --- a/apple/Sources/Watch/IronStorageWatchApp.swift +++ b/apple/Sources/Watch/IronStorageWatchApp.swift @@ -1,7 +1,54 @@ import SwiftUI +import WatchConnectivity + +private final class WatchSnapshotTransport: NSObject, ObservableObject, WCSessionDelegate { + private static let snapshotKey = "de.rfc1437.ironstorage.watch.snapshot" + private static let deliveredReceiptKey = "de.rfc1437.ironstorage.watch.delivered" + + override init() { + super.init() + guard WCSession.isSupported() else { return } + let session = WCSession.default + session.delegate = self + session.activate() + } + + private func receive(_ applicationContext: [String: Any], session: WCSession) { + guard + applicationContext[Self.snapshotKey] is Data, + let receipt = applicationContext[Self.deliveredReceiptKey] as? Data + else { return } + let acknowledgement = [Self.deliveredReceiptKey: receipt] + if session.isReachable { + session.sendMessage(acknowledgement, replyHandler: nil) { _ in + session.transferUserInfo(acknowledgement) + } + } else { + session.transferUserInfo(acknowledgement) + } + } + + func session( + _ session: WCSession, + activationDidCompleteWith activationState: WCSessionActivationState, + error: Error? + ) { + guard activationState == .activated, error == nil else { return } + receive(session.receivedApplicationContext, session: session) + } + + func session( + _ session: WCSession, + didReceiveApplicationContext applicationContext: [String: Any] + ) { + receive(applicationContext, session: session) + } +} @main struct IronStorageWatchApp: App { + @StateObject private var transport = WatchSnapshotTransport() + var body: some Scene { WindowGroup { ContentUnavailableView("No TOTP Codes", systemImage: "timer") diff --git a/crates/apple/src/lib.rs b/crates/apple/src/lib.rs index 1df69f8..75a5cdc 100644 --- a/crates/apple/src/lib.rs +++ b/crates/apple/src/lib.rs @@ -59,8 +59,11 @@ use ironstorage::{ MobileTotpDetail as StorageTotpDetail, MobileTotpDiscoveryPhase as StorageTotpDiscoveryPhase, MobileTotpOperation as StorageTotpOperation, MobileTotpPage as StorageTotpPage, + }, + mobile_watch::{ MobileWatchSnapshotState as StorageWatchSnapshotState, MobileWatchSnapshotStatus as StorageWatchSnapshotStatus, + WatchSnapshotTransfer as StorageWatchSnapshotTransfer, }, }; @@ -659,6 +662,9 @@ pub enum MobileWatchPreferenceState { AppNotInstalled, Ready, Pending, + Delivered, + Current, + Failed, } impl From for MobileWatchPreferenceState { @@ -669,6 +675,9 @@ impl From for MobileWatchPreferenceState { StorageWatchPreferenceState::AppNotInstalled => Self::AppNotInstalled, StorageWatchPreferenceState::Ready => Self::Ready, StorageWatchPreferenceState::Pending => Self::Pending, + StorageWatchPreferenceState::Delivered => Self::Delivered, + StorageWatchPreferenceState::Current => Self::Current, + StorageWatchPreferenceState::Failed => Self::Failed, } } } @@ -922,14 +931,22 @@ pub struct MobileEntryCopy { #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] pub enum MobileWatchSnapshotState { Unavailable, + Unpaired, Pending, + Delivered, + Current, + Failed, } impl From for MobileWatchSnapshotState { fn from(state: StorageWatchSnapshotState) -> Self { match state { StorageWatchSnapshotState::Unavailable => Self::Unavailable, + StorageWatchSnapshotState::Unpaired => Self::Unpaired, StorageWatchSnapshotState::Pending => Self::Pending, + StorageWatchSnapshotState::Delivered => Self::Delivered, + StorageWatchSnapshotState::Current => Self::Current, + StorageWatchSnapshotState::Failed => Self::Failed, } } } @@ -937,6 +954,7 @@ impl From for MobileWatchSnapshotState { #[derive(Clone, uniffi::Record)] pub struct MobileWatchSnapshotStatus { pub state: MobileWatchSnapshotState, + pub revision: Option, pub detail: String, } @@ -944,11 +962,31 @@ impl From<&StorageWatchSnapshotStatus> for MobileWatchSnapshotStatus { fn from(status: &StorageWatchSnapshotStatus) -> Self { Self { state: status.state().into(), + revision: status.revision(), detail: status.detail().to_owned(), } } } +#[derive(Clone, uniffi::Record)] +pub struct MobileWatchSnapshotTransfer { + pub revision: u64, + pub selected_entries: u32, + pub snapshot: Vec, + pub delivered_receipt: Vec, +} + +impl From for MobileWatchSnapshotTransfer { + fn from(transfer: StorageWatchSnapshotTransfer) -> Self { + Self { + revision: transfer.revision(), + selected_entries: transfer.selected_entries(), + snapshot: transfer.snapshot().expose().to_vec(), + delivered_receipt: transfer.delivered_receipt().to_vec(), + } + } +} + #[derive(Clone, uniffi::Record)] pub struct MobileTotpRow { pub path: String, @@ -1724,6 +1762,57 @@ impl MobileAuthentication { .map_err(Into::into) } + pub fn watch_snapshot_status( + &self, + ) -> Result { + self.authentication + .watch_snapshot_status() + .map(|status| (&status).into()) + .map_err(Into::into) + } + + pub fn prepare_watch_snapshot( + &self, + platform_pairing_identity: String, + ) -> Result { + self.authentication + .prepare_watch_snapshot(&platform_pairing_identity) + .map(Into::into) + .map_err(Into::into) + } + + pub fn set_watch_snapshot_unavailable( + &self, + unpaired: bool, + detail: String, + ) -> Result { + self.authentication + .set_watch_snapshot_unavailable(unpaired, detail) + .map(|status| (&status).into()) + .map_err(Into::into) + } + + pub fn fail_watch_snapshot( + &self, + revision: u64, + detail: String, + ) -> Result { + self.authentication + .fail_watch_snapshot(revision, detail) + .map(|status| (&status).into()) + .map_err(Into::into) + } + + pub fn acknowledge_watch_snapshot( + &self, + receipt: Vec, + ) -> Result { + self.authentication + .acknowledge_watch_snapshot(&receipt) + .map(|status| (&status).into()) + .map_err(Into::into) + } + pub fn begin_create_entry( &self, directory: String, diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index c9eb45c..734bd28 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -23,6 +23,7 @@ pub mod mobile_mutation; pub mod mobile_onboarding; pub mod mobile_passwords; pub mod mobile_totp; +pub mod mobile_watch; pub mod mutation; pub mod otp; pub mod presentation; diff --git a/crates/storage/src/mobile_authentication.rs b/crates/storage/src/mobile_authentication.rs index 7c758be..4adbb09 100644 --- a/crates/storage/src/mobile_authentication.rs +++ b/crates/storage/src/mobile_authentication.rs @@ -23,6 +23,10 @@ use crate::{ mobile_totp::{ MobileTotpDetail, MobileTotpError, MobileTotpOperation, MobileTotpPage, MobileTotpService, }, + mobile_watch::{ + MobileWatchSnapshotState, MobileWatchSnapshotStatus, WatchSnapshotError, + WatchSnapshotSender, WatchSnapshotTransfer, + }, otp::OtpError, recipient::RecipientPolicyManager, repository::{ @@ -139,6 +143,9 @@ pub enum MobileWatchPreferenceState { AppNotInstalled, Ready, Pending, + Delivered, + Current, + Failed, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -282,6 +289,7 @@ struct MobileAuthenticationStatus { mutation_active: bool, repository_operation_active: bool, watch_shared_totp_entries: std::collections::BTreeSet, + watch_snapshot: WatchSnapshotSender, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -338,6 +346,8 @@ impl MobileAuthentication { config.authentication_timeout(), ) .map_err(MobileAuthenticationError::authentication)?; + let watch_snapshot = + WatchSnapshotSender::load(config.source().with_file_name("watch-snapshot.toml")); Ok(Self { status: Mutex::new(MobileAuthenticationStatus { biometric_unlock_enabled: config.biometric_unlock_enabled(), @@ -350,6 +360,7 @@ impl MobileAuthentication { mutation_active: false, repository_operation_active: false, watch_shared_totp_entries: config.watch_shared_totp_entries().clone(), + watch_snapshot, }), config, repository, @@ -564,8 +575,14 @@ impl MobileAuthentication { .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 snapshot = status.watch_snapshot.status(); + let (watch_state, watch_title, watch_detail) = watch_preference( + watch_supported, + watch_paired, + watch_app_installed, + selected, + &snapshot, + ); let authentication_timeout_seconds = status.authentication_timeout.duration().as_secs(); let biometric_unlock_enabled = status.biometric_unlock_enabled; let appearance = status.appearance; @@ -681,7 +698,10 @@ impl MobileAuthentication { &cache_path, ) .map_err(totp_error)?; - let (totp, cache_notice) = reconciliation.into_parts(); + let (mut totp, cache_notice) = reconciliation.into_parts(); + if let Some(detail) = &mut totp { + detail.set_watch(self.watch_snapshot_status()?); + } Ok(MobileEntryPresentation { page: MobileEntryPage::from_document(&document), totp, @@ -808,9 +828,11 @@ impl MobileAuthentication { }; let cache_path = self.config.source().with_file_name("totp-catalog.toml"); let mut provider = KeyOnlyProvider::new(handle, &key); - MobileTotpService::new(&self.repository, &self.keys) + let mut page = MobileTotpService::new(&self.repository, &self.keys) .discover(&shared, &mut provider, &cache_path, operation) - .map_err(totp_error) + .map_err(totp_error)?; + page.set_watch(self.watch_snapshot_status()?); + Ok(page) } pub fn cached_totp_page(&self) -> Result, MobileAuthenticationError> { @@ -823,13 +845,15 @@ impl MobileAuthentication { ) -> Result, MobileAuthenticationError> { let shared = self.status()?.watch_shared_totp_entries.clone(); let cache_path = self.config.source().with_file_name("totp-catalog.toml"); - Ok( - MobileTotpService::new(&self.repository, &self.keys).search_cached_page( - &shared, - &cache_path, - query, - ), - ) + let mut page = MobileTotpService::new(&self.repository, &self.keys).search_cached_page( + &shared, + &cache_path, + query, + ); + if let Some(page) = &mut page { + page.set_watch(self.watch_snapshot_status()?); + } + Ok(page) } pub fn totp_detail( @@ -848,9 +872,11 @@ impl MobileAuthentication { ) }; let mut provider = KeyOnlyProvider::new(handle, &key); - MobileTotpService::new(&self.repository, &self.keys) + let mut detail = MobileTotpService::new(&self.repository, &self.keys) .detail(path, unix_seconds, &shared, &mut provider) - .map_err(totp_error) + .map_err(totp_error)?; + detail.set_watch(self.watch_snapshot_status()?); + Ok(detail) } pub fn copy_totp_code( @@ -888,6 +914,85 @@ impl MobileAuthentication { self.totp_detail(path, unix_seconds) } + pub fn watch_snapshot_status( + &self, + ) -> Result { + Ok(self.status()?.watch_snapshot.status()) + } + + pub fn prepare_watch_snapshot( + &self, + platform_pairing_identity: &str, + ) -> Result { + self.ensure_active()?; + let (handle, key, selected) = { + let status = self.status()?; + let active = status.active.as_ref().ok_or_else(locked_error)?; + ( + active.handle.clone(), + active.key.clone(), + status.watch_shared_totp_entries.clone(), + ) + }; + let mut provider = KeyOnlyProvider::new(handle, &key); + let selection = MobileTotpService::new(&self.repository, &self.keys) + .watch_snapshot_selection(&selected, &mut provider) + .map_err(totp_error)?; + let (entries, retained) = selection.into_parts(); + let mut status = self.status()?; + if status.watch_shared_totp_entries != selected { + return Err(entry_detail( + "Apple Watch Snapshot Changed", + "the selected TOTP entries changed while the snapshot was being prepared", + )); + } + if retained != selected { + drop(status); + self.config + .update_watch_shared_totp_entries(&retained) + .map_err(config_error)?; + status = self.status()?; + status.watch_shared_totp_entries = retained; + } + status + .watch_snapshot + .prepare(platform_pairing_identity, entries) + .map_err(watch_error) + } + + pub fn set_watch_snapshot_unavailable( + &self, + unpaired: bool, + detail: String, + ) -> Result { + let mut status = self.status()?; + status + .watch_snapshot + .unavailable(unpaired, detail) + .map_err(watch_error)?; + Ok(status.watch_snapshot.status()) + } + + pub fn fail_watch_snapshot( + &self, + revision: u64, + detail: String, + ) -> Result { + let mut status = self.status()?; + status.watch_snapshot.failed(revision, detail); + Ok(status.watch_snapshot.status()) + } + + pub fn acknowledge_watch_snapshot( + &self, + receipt: &[u8], + ) -> Result { + self.status()? + .watch_snapshot + .acknowledge(receipt) + .map_err(watch_error) + } + pub fn replace_entry_field( &self, path: &str, @@ -1430,6 +1535,7 @@ fn watch_preference( paired: bool, app_installed: bool, selected: usize, + snapshot: &MobileWatchSnapshotStatus, ) -> (MobileWatchPreferenceState, String, String) { if !supported { return ( @@ -1452,25 +1558,50 @@ fn watch_preference( "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" - } + match snapshot.state() { + MobileWatchSnapshotState::Pending => ( + MobileWatchPreferenceState::Pending, + "Synchronization Pending".to_owned(), + snapshot.detail().to_owned(), ), - ) + MobileWatchSnapshotState::Delivered => ( + MobileWatchPreferenceState::Delivered, + "Delivered".to_owned(), + snapshot.detail().to_owned(), + ), + MobileWatchSnapshotState::Current => ( + MobileWatchPreferenceState::Current, + "Current".to_owned(), + snapshot.detail().to_owned(), + ), + MobileWatchSnapshotState::Failed => ( + MobileWatchPreferenceState::Failed, + "Synchronization Failed".to_owned(), + snapshot.detail().to_owned(), + ), + MobileWatchSnapshotState::Unavailable | MobileWatchSnapshotState::Unpaired => { + if selected == 0 { + ( + MobileWatchPreferenceState::Ready, + "Ready".to_owned(), + "No TOTP codes are selected for Apple Watch.".to_owned(), + ) + } else { + ( + 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 { @@ -1565,6 +1696,14 @@ fn totp_error(error: MobileTotpError) -> MobileAuthenticationError { } } +fn watch_error(error: WatchSnapshotError) -> MobileAuthenticationError { + MobileAuthenticationError::new( + MobileAuthenticationErrorKind::Entry, + "Apple Watch Synchronization Failed", + error.to_string(), + ) +} + fn editor_missing() -> MobileAuthenticationError { entry_detail( "Entry Draft Is Unavailable", @@ -1584,7 +1723,8 @@ fn entry_detail(title: &str, error: impl fmt::Display) -> MobileAuthenticationEr mod tests { use super::{ MobileRepositoryOperation, MobileRepositoryOperationError, MobileWatchPreferenceState, - repository_operation_conflict, watch_preference, + MobileWatchSnapshotState, MobileWatchSnapshotStatus, repository_operation_conflict, + watch_preference, }; #[test] @@ -1613,15 +1753,20 @@ mod tests { #[test] fn watch_preferences_render_platform_and_selection_state() { + let unavailable = MobileWatchSnapshotStatus { + state: MobileWatchSnapshotState::Unavailable, + revision: None, + detail: "not started".to_owned(), + }; assert_eq!( - watch_preference(true, false, false, 2).0, + watch_preference(true, false, false, 2, &unavailable).0, MobileWatchPreferenceState::NotPaired ); assert_eq!( - watch_preference(true, true, true, 0).0, + watch_preference(true, true, true, 0, &unavailable).0, MobileWatchPreferenceState::Ready ); - let pending = watch_preference(true, true, true, 2); + let pending = watch_preference(true, true, true, 2, &unavailable); assert_eq!(pending.0, MobileWatchPreferenceState::Pending); assert!(pending.2.contains("2 selected TOTP codes")); } diff --git a/crates/storage/src/mobile_totp.rs b/crates/storage/src/mobile_totp.rs index 3929403..246efeb 100644 --- a/crates/storage/src/mobile_totp.rs +++ b/crates/storage/src/mobile_totp.rs @@ -21,6 +21,7 @@ use sha2::{Digest as _, Sha256}; use crate::{ crypto::{KeyStore, SecretProvider}, document::EntryDocument, + mobile_watch::{MobileWatchSnapshotState, MobileWatchSnapshotStatus, WatchSnapshotEntry}, otp::{OtpError, OtpKind, OtpService}, repository::{EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes}, }; @@ -124,28 +125,6 @@ impl MobileTotpOperation { } } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum MobileWatchSnapshotState { - Unavailable, - Pending, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct MobileWatchSnapshotStatus { - state: MobileWatchSnapshotState, - detail: String, -} - -impl MobileWatchSnapshotStatus { - pub fn state(&self) -> MobileWatchSnapshotState { - self.state - } - - pub fn detail(&self) -> &str { - &self.detail - } -} - #[derive(Clone, Debug, Eq, PartialEq)] pub struct MobileTotpRow { path: String, @@ -206,6 +185,10 @@ impl MobileTotpPage { pub fn watch(&self) -> &MobileWatchSnapshotStatus { &self.watch } + + pub(crate) fn set_watch(&mut self, watch: MobileWatchSnapshotStatus) { + self.watch = watch; + } } pub struct MobileTotpDetail { @@ -251,6 +234,21 @@ impl MobileTotpDetail { pub fn watch(&self) -> &MobileWatchSnapshotStatus { &self.watch } + + pub(crate) fn set_watch(&mut self, watch: MobileWatchSnapshotStatus) { + self.watch = watch; + } +} + +pub struct WatchSnapshotSelection { + entries: Vec, + retained: BTreeSet, +} + +impl WatchSnapshotSelection { + pub fn into_parts(self) -> (Vec, BTreeSet) { + (self.entries, self.retained) + } } impl fmt::Debug for MobileTotpDetail { @@ -500,6 +498,40 @@ impl<'a> MobileTotpService<'a> { } detail_for_uri(&path, uri, unix_seconds, shared) } + + pub fn watch_snapshot_selection( + &self, + selected: &BTreeSet, + provider: &mut impl SecretProvider, + ) -> Result { + let mut entries = Vec::with_capacity(selected.len()); + let mut retained = BTreeSet::new(); + for path in selected { + let uri = match OtpService::new(self.repository, self.keys) + .uri(&path.to_string(), provider) + { + Ok(uri) if uri.kind() == OtpKind::Totp => uri, + Ok(_) => continue, + Err(OtpError::Repository(RepositoryError::NotFound { .. })) + | Err(OtpError::MissingUri { .. } | OtpError::AmbiguousUri { .. }) => continue, + Err(OtpError::Crypto(error)) => return Err(OtpError::Crypto(error).into()), + Err(OtpError::Repository(error)) => return Err(error.into()), + Err(_) => continue, + }; + let period = uri.period().ok_or(OtpError::NotTotp)?; + retained.insert(path.clone()); + entries.push(WatchSnapshotEntry::new( + path.clone(), + uri.issuer().map(str::to_owned), + uri.account().to_owned(), + uri.algorithm(), + uri.digits(), + period, + uri.watch_secret(), + )); + } + Ok(WatchSnapshotSelection { entries, retained }) + } } fn detail_for_uri( @@ -755,11 +787,13 @@ fn snapshot_status(selected: usize) -> MobileWatchSnapshotStatus { if selected == 0 { MobileWatchSnapshotStatus { state: MobileWatchSnapshotState::Unavailable, + revision: None, detail: "No TOTP codes are selected for Apple Watch.".to_owned(), } } else { MobileWatchSnapshotStatus { state: MobileWatchSnapshotState::Pending, + revision: None, detail: format!( "{selected} selected TOTP {} pending Apple Watch synchronization.", if selected == 1 { diff --git a/crates/storage/src/mobile_watch.rs b/crates/storage/src/mobile_watch.rs new file mode 100644 index 0000000..6bedd18 --- /dev/null +++ b/crates/storage/src/mobile_watch.rs @@ -0,0 +1,868 @@ +//! Versioned, replacement-only Apple Watch TOTP snapshots and sender state. + +use std::{ + error::Error, + fmt, fs, + io::Write as _, + path::{Path, PathBuf}, +}; + +use cap_std::{ambient_authority, fs::Dir}; +use cap_tempfile::TempFile; +use data_encoding::{HEXLOWER, HEXLOWER_PERMISSIVE}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; + +use crate::{ + otp::OtpAlgorithm, + repository::{EntryPath, SecretBytes}, +}; + +const SNAPSHOT_MAGIC: &[u8; 4] = b"ISWS"; +const RECEIPT_MAGIC: &[u8; 4] = b"ISWR"; +const SNAPSHOT_VERSION: u16 = 1; +const JOURNAL_VERSION: u32 = 1; +const MAX_SNAPSHOT_BYTES: usize = 256 * 1024; +const MAX_ENTRIES: usize = 256; +const MAX_TEXT_BYTES: usize = 4096; +const MAX_SECRET_BYTES: usize = 1024; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MobileWatchSnapshotState { + Unavailable, + Unpaired, + Pending, + Delivered, + Current, + Failed, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MobileWatchSnapshotStatus { + pub(crate) state: MobileWatchSnapshotState, + pub(crate) revision: Option, + pub(crate) detail: String, +} + +impl MobileWatchSnapshotStatus { + pub fn state(&self) -> MobileWatchSnapshotState { + self.state + } + pub fn revision(&self) -> Option { + self.revision + } + pub fn detail(&self) -> &str { + &self.detail + } +} + +pub struct WatchSnapshotEntry { + path: EntryPath, + issuer: Option, + account: String, + algorithm: OtpAlgorithm, + digits: u32, + period: u64, + secret: SecretBytes, +} + +impl WatchSnapshotEntry { + pub fn new( + path: EntryPath, + issuer: Option, + account: String, + algorithm: OtpAlgorithm, + digits: u32, + period: u64, + secret: SecretBytes, + ) -> Self { + Self { + path, + issuer, + account, + algorithm, + digits, + period, + secret, + } + } + + pub fn path(&self) -> &EntryPath { + &self.path + } + pub fn issuer(&self) -> Option<&str> { + self.issuer.as_deref() + } + pub fn account(&self) -> &str { + &self.account + } + pub fn algorithm(&self) -> OtpAlgorithm { + self.algorithm + } + pub fn digits(&self) -> u32 { + self.digits + } + pub fn period(&self) -> u64 { + self.period + } + pub fn secret(&self) -> &SecretBytes { + &self.secret + } +} + +impl fmt::Debug for WatchSnapshotEntry { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WatchSnapshotEntry") + .field("path", &self.path) + .field("issuer", &self.issuer) + .field("account", &self.account) + .field("algorithm", &self.algorithm) + .field("digits", &self.digits) + .field("period", &self.period) + .field("secret", &"[REDACTED]") + .finish() + } +} + +pub struct WatchSnapshot { + pairing: [u8; 32], + revision: u64, + entries: Vec, + digest: [u8; 32], +} + +impl WatchSnapshot { + pub fn revision(&self) -> u64 { + self.revision + } + pub fn entries(&self) -> &[WatchSnapshotEntry] { + &self.entries + } + pub fn is_revocation(&self) -> bool { + self.entries.is_empty() + } +} + +impl fmt::Debug for WatchSnapshot { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WatchSnapshot") + .field("pairing", &HEXLOWER.encode(&self.pairing)) + .field("revision", &self.revision) + .field("entries", &self.entries) + .field("digest", &HEXLOWER.encode(&self.digest)) + .finish() + } +} + +pub struct WatchSnapshotTransfer { + revision: u64, + selected_entries: u32, + snapshot: SecretBytes, + delivered_receipt: Vec, +} + +impl WatchSnapshotTransfer { + pub fn revision(&self) -> u64 { + self.revision + } + pub fn selected_entries(&self) -> u32 { + self.selected_entries + } + pub fn snapshot(&self) -> &SecretBytes { + &self.snapshot + } + pub fn delivered_receipt(&self) -> &[u8] { + &self.delivered_receipt + } +} + +impl fmt::Debug for WatchSnapshotTransfer { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WatchSnapshotTransfer") + .field("revision", &self.revision) + .field("selected_entries", &self.selected_entries) + .field("snapshot", &"[REDACTED]") + .field("delivered_receipt", &"[OPAQUE]") + .finish() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WatchSnapshotApply { + Replaced, + Revoked, + Duplicate, + Stale, + PairingChanged, +} + +#[derive(Default)] +pub struct WatchSnapshotReceiver { + current: Option, + accepted: Option, +} + +#[derive(Clone, Copy)] +struct AcceptedSnapshot { + pairing: [u8; 32], + revision: u64, + digest: [u8; 32], +} + +impl WatchSnapshotReceiver { + pub fn apply(&mut self, bytes: SecretBytes) -> Result { + let snapshot = match decode_snapshot(bytes.expose()) { + Ok(snapshot) => snapshot, + Err(error) => { + self.current = None; + return Err(error); + } + }; + let mut pairing_changed = false; + if let Some(accepted) = self.accepted { + if snapshot.revision < accepted.revision { + return Ok(WatchSnapshotApply::Stale); + } else if snapshot.revision == accepted.revision { + if snapshot.pairing != accepted.pairing || snapshot.digest != accepted.digest { + self.current = None; + return Err(WatchSnapshotError::RevisionConflict); + } + if self.current.is_some() { + return Ok(WatchSnapshotApply::Duplicate); + } + } else if accepted.pairing != snapshot.pairing { + pairing_changed = true; + } + } + let accepted = AcceptedSnapshot { + pairing: snapshot.pairing, + revision: snapshot.revision, + digest: snapshot.digest, + }; + let result = if pairing_changed { + WatchSnapshotApply::PairingChanged + } else if snapshot.is_revocation() { + WatchSnapshotApply::Revoked + } else { + WatchSnapshotApply::Replaced + }; + self.accepted = Some(accepted); + self.current = Some(snapshot); + Ok(result) + } + + pub fn current(&self) -> Option<&WatchSnapshot> { + self.current.as_ref() + } + + pub fn current_receipt(&self) -> Option> { + self.current.as_ref().map(|snapshot| { + encode_receipt( + ReceiptKind::Current, + snapshot.pairing, + snapshot.revision, + snapshot.digest, + ) + }) + } + + pub fn revoke(&mut self) { + self.current = None; + self.accepted = None; + } +} + +pub struct WatchSnapshotSender { + path: PathBuf, + journal: SenderJournal, + status: MobileWatchSnapshotStatus, +} + +impl WatchSnapshotSender { + pub fn load(path: PathBuf) -> Self { + let journal = load_journal(&path).unwrap_or_default(); + let status = status_from_journal(&journal); + Self { + path, + journal, + status, + } + } + + pub fn status(&self) -> MobileWatchSnapshotStatus { + self.status.clone() + } + + pub fn unavailable( + &mut self, + unpaired: bool, + detail: impl Into, + ) -> Result<(), WatchSnapshotError> { + if unpaired { + self.journal.pairing = None; + self.journal.digest = None; + self.journal.snapshot_digest = None; + self.journal.delivered_revision = None; + self.journal.current_revision = None; + save_journal(&self.path, &self.journal)?; + } + self.status = MobileWatchSnapshotStatus { + state: if unpaired { + MobileWatchSnapshotState::Unpaired + } else { + MobileWatchSnapshotState::Unavailable + }, + revision: None, + detail: detail.into(), + }; + Ok(()) + } + + pub fn prepare( + &mut self, + platform_pairing_identity: &str, + entries: Vec, + ) -> Result { + if platform_pairing_identity.trim().is_empty() { + return Err(WatchSnapshotError::InvalidPairing); + } + let pairing = digest(platform_pairing_identity.as_bytes()); + let content = encode_entries(&entries)?; + let content_digest = digest(&content); + let pairing_text = HEXLOWER.encode(&pairing); + let digest_text = HEXLOWER.encode(&content_digest); + if self.journal.pairing.as_deref() != Some(&pairing_text) + || self.journal.digest.as_deref() != Some(&digest_text) + { + self.journal.revision = self + .journal + .revision + .checked_add(1) + .ok_or(WatchSnapshotError::RevisionOverflow)?; + self.journal.pairing = Some(pairing_text); + self.journal.digest = Some(digest_text); + self.journal.delivered_revision = None; + self.journal.current_revision = None; + save_journal(&self.path, &self.journal)?; + } + if self.journal.revision == 0 { + self.journal.revision = 1; + save_journal(&self.path, &self.journal)?; + } + let snapshot = encode_snapshot(pairing, self.journal.revision, &content)?; + let snapshot_digest = digest(snapshot.expose()); + self.journal.snapshot_digest = Some(HEXLOWER.encode(&snapshot_digest)); + save_journal(&self.path, &self.journal)?; + let receipt = encode_receipt( + ReceiptKind::Delivered, + pairing, + self.journal.revision, + snapshot_digest, + ); + let selected_entries = + u32::try_from(entries.len()).map_err(|_| WatchSnapshotError::TooManyEntries)?; + self.status = MobileWatchSnapshotStatus { + state: MobileWatchSnapshotState::Pending, + revision: Some(self.journal.revision), + detail: snapshot_detail("pending delivery", selected_entries, self.journal.revision), + }; + Ok(WatchSnapshotTransfer { + revision: self.journal.revision, + selected_entries, + snapshot, + delivered_receipt: receipt, + }) + } + + pub fn failed(&mut self, revision: u64, detail: impl Into) { + if revision == self.journal.revision { + self.status = MobileWatchSnapshotStatus { + state: MobileWatchSnapshotState::Failed, + revision: Some(revision), + detail: detail.into(), + }; + } + } + + pub fn acknowledge( + &mut self, + receipt: &[u8], + ) -> Result { + let receipt = decode_receipt(receipt)?; + let pairing = decode_hash( + self.journal + .pairing + .as_deref() + .ok_or(WatchSnapshotError::NoPendingSnapshot)?, + )?; + if receipt.pairing != pairing || receipt.revision != self.journal.revision { + return Ok(self.status()); + } + let expected_snapshot_digest = decode_hash( + self.journal + .snapshot_digest + .as_deref() + .ok_or(WatchSnapshotError::NoPendingSnapshot)?, + )?; + if receipt._digest != expected_snapshot_digest { + return Err(WatchSnapshotError::InvalidReceipt); + } + if receipt.kind == ReceiptKind::Delivered { + self.journal.delivered_revision = Some(receipt.revision); + self.status = MobileWatchSnapshotStatus { + state: MobileWatchSnapshotState::Delivered, + revision: Some(receipt.revision), + detail: format!( + "Snapshot revision {} reached the paired Apple Watch.", + receipt.revision + ), + }; + } else { + self.journal.current_revision = Some(receipt.revision); + self.status = MobileWatchSnapshotStatus { + state: MobileWatchSnapshotState::Current, + revision: Some(receipt.revision), + detail: format!( + "Apple Watch is current at snapshot revision {}.", + receipt.revision + ), + }; + } + save_journal(&self.path, &self.journal)?; + Ok(self.status()) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct SenderJournal { + #[serde(default = "journal_version")] + version: u32, + #[serde(default)] + pairing: Option, + #[serde(default)] + revision: u64, + #[serde(default)] + digest: Option, + #[serde(default)] + snapshot_digest: Option, + #[serde(default)] + delivered_revision: Option, + #[serde(default)] + current_revision: Option, +} + +impl Default for SenderJournal { + fn default() -> Self { + Self { + version: JOURNAL_VERSION, + pairing: None, + revision: 0, + digest: None, + snapshot_digest: None, + delivered_revision: None, + current_revision: None, + } + } +} + +fn journal_version() -> u32 { + JOURNAL_VERSION +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u8)] +enum ReceiptKind { + Delivered = 1, + Current = 2, +} + +struct Receipt { + kind: ReceiptKind, + pairing: [u8; 32], + revision: u64, + _digest: [u8; 32], +} + +fn encode_entries(entries: &[WatchSnapshotEntry]) -> Result, WatchSnapshotError> { + if entries.len() > MAX_ENTRIES { + return Err(WatchSnapshotError::TooManyEntries); + } + let mut output = Vec::new(); + put_u32( + &mut output, + u32::try_from(entries.len()).map_err(|_| WatchSnapshotError::TooManyEntries)?, + ); + for entry in entries { + put_text(&mut output, &entry.path.to_string())?; + match &entry.issuer { + Some(issuer) => { + output.push(1); + put_text(&mut output, issuer)?; + } + None => output.push(0), + } + put_text(&mut output, &entry.account)?; + output.push(match entry.algorithm { + OtpAlgorithm::Sha1 => 1, + OtpAlgorithm::Sha256 => 2, + OtpAlgorithm::Sha512 => 3, + }); + output.push(u8::try_from(entry.digits).map_err(|_| WatchSnapshotError::InvalidEntry)?); + put_u64(&mut output, entry.period); + put_bytes(&mut output, entry.secret.expose(), MAX_SECRET_BYTES)?; + } + Ok(output) +} + +fn encode_snapshot( + pairing: [u8; 32], + revision: u64, + content: &[u8], +) -> Result { + let mut output = Vec::with_capacity(4 + 2 + 32 + 8 + content.len() + 32); + output.extend_from_slice(SNAPSHOT_MAGIC); + output.extend_from_slice(&SNAPSHOT_VERSION.to_be_bytes()); + output.extend_from_slice(&pairing); + put_u64(&mut output, revision); + output.extend_from_slice(content); + let checksum = digest(&output); + output.extend_from_slice(&checksum); + if output.len() > MAX_SNAPSHOT_BYTES { + return Err(WatchSnapshotError::SnapshotTooLarge); + } + Ok(SecretBytes::new(output)) +} + +fn decode_snapshot(bytes: &[u8]) -> Result { + if bytes.len() > MAX_SNAPSHOT_BYTES || bytes.len() < 4 + 2 + 32 + 8 + 4 + 32 { + return Err(WatchSnapshotError::InvalidSnapshot); + } + let (payload, checksum) = bytes.split_at(bytes.len() - 32); + if digest(payload).as_slice() != checksum { + return Err(WatchSnapshotError::InvalidChecksum); + } + let mut input = payload; + if take(&mut input, 4)? != SNAPSHOT_MAGIC || take_u16(&mut input)? != SNAPSHOT_VERSION { + return Err(WatchSnapshotError::UnsupportedVersion); + } + let pairing: [u8; 32] = take(&mut input, 32)? + .try_into() + .map_err(|_| WatchSnapshotError::InvalidSnapshot)?; + let revision = take_u64(&mut input)?; + if revision == 0 { + return Err(WatchSnapshotError::InvalidSnapshot); + } + let count = + usize::try_from(take_u32(&mut input)?).map_err(|_| WatchSnapshotError::TooManyEntries)?; + if count > MAX_ENTRIES { + return Err(WatchSnapshotError::TooManyEntries); + } + let mut entries = Vec::with_capacity(count); + for _ in 0..count { + let path = EntryPath::parse(&take_text(&mut input)?) + .map_err(|_| WatchSnapshotError::InvalidEntry)?; + let issuer = match take_u8(&mut input)? { + 0 => None, + 1 => Some(take_text(&mut input)?), + _ => return Err(WatchSnapshotError::InvalidEntry), + }; + let account = take_text(&mut input)?; + if account.is_empty() { + return Err(WatchSnapshotError::InvalidEntry); + } + let algorithm = match take_u8(&mut input)? { + 1 => OtpAlgorithm::Sha1, + 2 => OtpAlgorithm::Sha256, + 3 => OtpAlgorithm::Sha512, + _ => return Err(WatchSnapshotError::InvalidEntry), + }; + let digits = u32::from(take_u8(&mut input)?); + if !matches!(digits, 6 | 8) { + return Err(WatchSnapshotError::InvalidEntry); + } + let period = take_u64(&mut input)?; + if period == 0 { + return Err(WatchSnapshotError::InvalidEntry); + } + let secret = take_bytes(&mut input, MAX_SECRET_BYTES)?; + if secret.is_empty() { + return Err(WatchSnapshotError::InvalidEntry); + } + entries.push(WatchSnapshotEntry::new( + path, + issuer, + account, + algorithm, + digits, + period, + SecretBytes::new(secret), + )); + } + if !input.is_empty() { + return Err(WatchSnapshotError::InvalidSnapshot); + } + Ok(WatchSnapshot { + pairing, + revision, + entries, + digest: digest(bytes), + }) +} + +fn encode_receipt( + kind: ReceiptKind, + pairing: [u8; 32], + revision: u64, + snapshot_digest: [u8; 32], +) -> Vec { + let mut output = Vec::with_capacity(79); + output.extend_from_slice(RECEIPT_MAGIC); + output.extend_from_slice(&SNAPSHOT_VERSION.to_be_bytes()); + output.push(kind as u8); + output.extend_from_slice(&pairing); + put_u64(&mut output, revision); + output.extend_from_slice(&snapshot_digest); + let checksum = digest(&output); + output.extend_from_slice(&checksum); + output +} + +fn decode_receipt(bytes: &[u8]) -> Result { + if bytes.len() != 4 + 2 + 1 + 32 + 8 + 32 + 32 { + return Err(WatchSnapshotError::InvalidReceipt); + } + let (payload, checksum) = bytes.split_at(bytes.len() - 32); + if digest(payload).as_slice() != checksum { + return Err(WatchSnapshotError::InvalidReceipt); + } + let mut input = payload; + if take(&mut input, 4)? != RECEIPT_MAGIC || take_u16(&mut input)? != SNAPSHOT_VERSION { + return Err(WatchSnapshotError::InvalidReceipt); + } + let kind = match take_u8(&mut input)? { + 1 => ReceiptKind::Delivered, + 2 => ReceiptKind::Current, + _ => return Err(WatchSnapshotError::InvalidReceipt), + }; + let pairing = take(&mut input, 32)? + .try_into() + .map_err(|_| WatchSnapshotError::InvalidReceipt)?; + let revision = take_u64(&mut input)?; + let digest = take(&mut input, 32)? + .try_into() + .map_err(|_| WatchSnapshotError::InvalidReceipt)?; + Ok(Receipt { + kind, + pairing, + revision, + _digest: digest, + }) +} + +fn status_from_journal(journal: &SenderJournal) -> MobileWatchSnapshotStatus { + let (state, revision, detail) = if let Some(revision) = journal.current_revision { + ( + MobileWatchSnapshotState::Current, + Some(revision), + format!("Apple Watch is current at snapshot revision {revision}."), + ) + } else if let Some(revision) = journal.delivered_revision { + ( + MobileWatchSnapshotState::Delivered, + Some(revision), + format!("Snapshot revision {revision} reached the paired Apple Watch."), + ) + } else if journal.pairing.is_some() && journal.revision > 0 { + ( + MobileWatchSnapshotState::Pending, + Some(journal.revision), + format!( + "Snapshot revision {} is pending Apple Watch delivery.", + journal.revision + ), + ) + } else { + ( + MobileWatchSnapshotState::Unavailable, + None, + "Apple Watch synchronization has not started.".to_owned(), + ) + }; + MobileWatchSnapshotStatus { + state, + revision, + detail, + } +} + +fn snapshot_detail(state: &str, count: u32, revision: u64) -> String { + format!( + "Snapshot revision {revision} with {count} selected TOTP {} is {state}.", + if count == 1 { "entry" } else { "entries" } + ) +} + +fn load_journal(path: &Path) -> Option { + let metadata = fs::symlink_metadata(path).ok()?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return None; + } + let journal = toml::from_str::(&fs::read_to_string(path).ok()?).ok()?; + (journal.version == JOURNAL_VERSION).then_some(journal) +} + +fn save_journal(path: &Path, journal: &SenderJournal) -> Result<(), WatchSnapshotError> { + let parent = path.parent().ok_or(WatchSnapshotError::JournalWrite)?; + fs::create_dir_all(parent).map_err(|_| WatchSnapshotError::JournalWrite)?; + let directory = Dir::open_ambient_dir(parent, ambient_authority()) + .map_err(|_| WatchSnapshotError::JournalWrite)?; + let name = path.file_name().ok_or(WatchSnapshotError::JournalWrite)?; + if let Ok(metadata) = directory.symlink_metadata(name) + && (metadata.file_type().is_symlink() || !metadata.is_file()) + { + return Err(WatchSnapshotError::JournalWrite); + } + let serialized = toml::to_string(journal).map_err(|_| WatchSnapshotError::JournalWrite)?; + let mut temporary = TempFile::new(&directory).map_err(|_| WatchSnapshotError::JournalWrite)?; + set_private_permissions(&temporary)?; + temporary + .write_all(serialized.as_bytes()) + .and_then(|()| temporary.as_file().sync_all()) + .and_then(|()| temporary.replace(name)) + .and_then(|()| directory.open(".").and_then(|file| file.sync_all())) + .map_err(|_| WatchSnapshotError::JournalWrite) +} + +#[cfg(unix)] +fn set_private_permissions(temporary: &TempFile<'_>) -> Result<(), WatchSnapshotError> { + use cap_std::fs::{Permissions, PermissionsExt as _}; + temporary + .as_file() + .set_permissions(Permissions::from_mode(0o600)) + .map_err(|_| WatchSnapshotError::JournalWrite) +} + +#[cfg(not(unix))] +fn set_private_permissions(_temporary: &TempFile<'_>) -> Result<(), WatchSnapshotError> { + Ok(()) +} + +fn digest(bytes: &[u8]) -> [u8; 32] { + Sha256::digest(bytes).into() +} +fn decode_hash(text: &str) -> Result<[u8; 32], WatchSnapshotError> { + let bytes = HEXLOWER_PERMISSIVE + .decode(text.as_bytes()) + .map_err(|_| WatchSnapshotError::InvalidJournal)?; + bytes + .try_into() + .map_err(|_| WatchSnapshotError::InvalidJournal) +} +fn put_u32(output: &mut Vec, value: u32) { + output.extend_from_slice(&value.to_be_bytes()); +} +fn put_u64(output: &mut Vec, value: u64) { + output.extend_from_slice(&value.to_be_bytes()); +} +fn put_text(output: &mut Vec, value: &str) -> Result<(), WatchSnapshotError> { + put_bytes(output, value.as_bytes(), MAX_TEXT_BYTES) +} +fn put_bytes(output: &mut Vec, value: &[u8], max: usize) -> Result<(), WatchSnapshotError> { + if value.len() > max { + return Err(WatchSnapshotError::InvalidEntry); + } + put_u32( + output, + u32::try_from(value.len()).map_err(|_| WatchSnapshotError::InvalidEntry)?, + ); + output.extend_from_slice(value); + Ok(()) +} +fn take<'a>(input: &mut &'a [u8], count: usize) -> Result<&'a [u8], WatchSnapshotError> { + if input.len() < count { + return Err(WatchSnapshotError::InvalidSnapshot); + } + let (value, remainder) = input.split_at(count); + *input = remainder; + Ok(value) +} +fn take_u8(input: &mut &[u8]) -> Result { + Ok(take(input, 1)?[0]) +} +fn take_u16(input: &mut &[u8]) -> Result { + Ok(u16::from_be_bytes( + take(input, 2)? + .try_into() + .map_err(|_| WatchSnapshotError::InvalidSnapshot)?, + )) +} +fn take_u32(input: &mut &[u8]) -> Result { + Ok(u32::from_be_bytes( + take(input, 4)? + .try_into() + .map_err(|_| WatchSnapshotError::InvalidSnapshot)?, + )) +} +fn take_u64(input: &mut &[u8]) -> Result { + Ok(u64::from_be_bytes( + take(input, 8)? + .try_into() + .map_err(|_| WatchSnapshotError::InvalidSnapshot)?, + )) +} +fn take_bytes(input: &mut &[u8], max: usize) -> Result, WatchSnapshotError> { + let count = usize::try_from(take_u32(input)?).map_err(|_| WatchSnapshotError::InvalidEntry)?; + if count > max { + return Err(WatchSnapshotError::InvalidEntry); + } + Ok(take(input, count)?.to_vec()) +} +fn take_text(input: &mut &[u8]) -> Result { + String::from_utf8(take_bytes(input, MAX_TEXT_BYTES)?) + .map_err(|_| WatchSnapshotError::InvalidEntry) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum WatchSnapshotError { + InvalidPairing, + TooManyEntries, + InvalidEntry, + SnapshotTooLarge, + InvalidSnapshot, + InvalidChecksum, + UnsupportedVersion, + RevisionConflict, + RevisionOverflow, + InvalidReceipt, + NoPendingSnapshot, + InvalidJournal, + JournalWrite, +} + +impl fmt::Display for WatchSnapshotError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::InvalidPairing => "Apple Watch pairing identity is invalid", + Self::TooManyEntries => "too many TOTP entries are selected for Apple Watch", + Self::InvalidEntry => "an Apple Watch TOTP entry is invalid", + Self::SnapshotTooLarge => "the Apple Watch snapshot is too large", + Self::InvalidSnapshot => "the Apple Watch snapshot is invalid", + Self::InvalidChecksum => "the Apple Watch snapshot is damaged", + Self::UnsupportedVersion => "the Apple Watch snapshot version is unsupported", + Self::RevisionConflict => { + "the Apple Watch snapshot revision conflicts with different content" + } + Self::RevisionOverflow => "the Apple Watch snapshot revision is exhausted", + Self::InvalidReceipt => "the Apple Watch delivery receipt is invalid", + Self::NoPendingSnapshot => "there is no pending Apple Watch snapshot", + Self::InvalidJournal => "the Apple Watch synchronization journal is invalid", + Self::JournalWrite => "the Apple Watch synchronization journal could not be saved", + }) + } +} + +impl Error for WatchSnapshotError {} diff --git a/crates/storage/src/otp.rs b/crates/storage/src/otp.rs index dbc31ac..b993271 100644 --- a/crates/storage/src/otp.rs +++ b/crates/storage/src/otp.rs @@ -229,6 +229,10 @@ impl OtpUri { self.period } + pub(crate) fn watch_secret(&self) -> SecretBytes { + SecretBytes::new(self.secret.expose().to_vec()) + } + pub fn counter(&self) -> Option { self.counter } diff --git a/crates/storage/tests/mobile_totp.rs b/crates/storage/tests/mobile_totp.rs index be8287c..60f3345 100644 --- a/crates/storage/tests/mobile_totp.rs +++ b/crates/storage/tests/mobile_totp.rs @@ -12,8 +12,8 @@ use ironstorage::{ document::EntryDocumentService, mobile_totp::{ MobileTotpDiscoveryPhase, MobileTotpError, MobileTotpOperation, MobileTotpService, - MobileWatchSnapshotState, }, + mobile_watch::MobileWatchSnapshotState, otp::OtpError, recipient::RecipientPolicyManager, repository::{EncryptedEntry, EntryPath, Repository, SecretBytes}, diff --git a/crates/storage/tests/mobile_watch.rs b/crates/storage/tests/mobile_watch.rs new file mode 100644 index 0000000..60ced49 --- /dev/null +++ b/crates/storage/tests/mobile_watch.rs @@ -0,0 +1,167 @@ +#![forbid(unsafe_code)] + +use std::fs; + +use ironstorage::{ + mobile_watch::{ + MobileWatchSnapshotState, WatchSnapshotApply, WatchSnapshotEntry, WatchSnapshotReceiver, + WatchSnapshotSender, + }, + otp::OtpAlgorithm, + repository::{EntryPath, SecretBytes}, +}; + +type TestResult = Result<(), Box>; + +fn entry(path: &str, issuer: &str, account: &str, secret: &[u8]) -> WatchSnapshotEntry { + WatchSnapshotEntry::new( + EntryPath::parse(path).expect("fixture path"), + Some(issuer.to_owned()), + account.to_owned(), + OtpAlgorithm::Sha256, + 8, + 30, + SecretBytes::new(secret.to_vec()), + ) +} + +#[test] +fn replacement_snapshots_reject_replays_conflicts_and_pairing_changes() -> TestResult { + let directory = tempfile::tempdir()?; + let journal = directory.path().join("watch-snapshot.toml"); + let mut sender = WatchSnapshotSender::load(journal.clone()); + let first = sender.prepare( + "paired-watch-a", + vec![entry("otp/alice", "Acme", "alice", b"first-secret")], + )?; + assert_eq!(first.revision(), 1); + assert_eq!(sender.status().state(), MobileWatchSnapshotState::Pending); + + let mut receiver = WatchSnapshotReceiver::default(); + assert_eq!( + receiver.apply(SecretBytes::new(first.snapshot().expose().to_vec()))?, + WatchSnapshotApply::Replaced + ); + assert_eq!(receiver.current().expect("snapshot").entries().len(), 1); + assert_eq!( + sender.acknowledge(first.delivered_receipt())?.state(), + MobileWatchSnapshotState::Delivered + ); + assert_eq!( + sender + .acknowledge(&receiver.current_receipt().expect("accepted receipt"))? + .state(), + MobileWatchSnapshotState::Current + ); + + let duplicate = sender.prepare( + "paired-watch-a", + vec![entry("otp/alice", "Acme", "alice", b"first-secret")], + )?; + assert_eq!(duplicate.revision(), 1); + assert_eq!(duplicate.snapshot().expose(), first.snapshot().expose()); + assert_eq!( + receiver.apply(SecretBytes::new(duplicate.snapshot().expose().to_vec()))?, + WatchSnapshotApply::Duplicate + ); + + let replacement = sender.prepare( + "paired-watch-a", + vec![entry("otp/bob", "Acme", "bob", b"second-secret")], + )?; + assert_eq!(replacement.revision(), 2); + assert_eq!( + receiver.apply(SecretBytes::new(replacement.snapshot().expose().to_vec()))?, + WatchSnapshotApply::Replaced + ); + assert_eq!( + receiver.current().expect("replacement").entries()[0].account(), + "bob" + ); + assert_eq!( + receiver.apply(SecretBytes::new(first.snapshot().expose().to_vec()))?, + WatchSnapshotApply::Stale + ); + assert_eq!( + receiver.current().expect("stale ignored").entries()[0].account(), + "bob" + ); + + let revoked = sender.prepare("paired-watch-a", Vec::new())?; + assert_eq!(revoked.revision(), 3); + assert_eq!( + receiver.apply(SecretBytes::new(revoked.snapshot().expose().to_vec()))?, + WatchSnapshotApply::Revoked + ); + assert!( + receiver + .current() + .expect("revocation marker") + .is_revocation() + ); + assert_eq!( + receiver.apply(SecretBytes::new(replacement.snapshot().expose().to_vec()))?, + WatchSnapshotApply::Stale + ); + + let changed_watch = sender.prepare( + "paired-watch-b", + vec![entry("otp/carol", "Acme", "carol", b"third-secret")], + )?; + assert_eq!(changed_watch.revision(), 4); + assert_eq!( + receiver.apply(SecretBytes::new(changed_watch.snapshot().expose().to_vec()))?, + WatchSnapshotApply::PairingChanged + ); + assert_eq!(receiver.current().expect("new pairing").revision(), 4); + let mut fresh_watch = WatchSnapshotReceiver::default(); + assert_eq!( + fresh_watch.apply(SecretBytes::new(changed_watch.snapshot().expose().to_vec()))?, + WatchSnapshotApply::Replaced + ); + + let mut damaged = changed_watch.snapshot().expose().to_vec(); + damaged[20] ^= 0x55; + assert!(fresh_watch.apply(SecretBytes::new(damaged)).is_err()); + assert!(fresh_watch.current().is_none()); + assert_eq!( + fresh_watch.apply(SecretBytes::new(first.snapshot().expose().to_vec()))?, + WatchSnapshotApply::Stale + ); + assert_eq!( + fresh_watch.apply(SecretBytes::new(changed_watch.snapshot().expose().to_vec()))?, + WatchSnapshotApply::Replaced + ); + + let persisted = fs::read_to_string(journal)?; + for forbidden in [ + "first-secret", + "second-secret", + "third-secret", + "otpauth://", + "94287082", + ] { + assert!(!persisted.contains(forbidden), "journal leaked {forbidden}"); + } + Ok(()) +} + +#[test] +fn journal_keeps_revisions_monotonic_across_sender_reloads() -> TestResult { + let directory = tempfile::tempdir()?; + let journal = directory.path().join("watch-snapshot.toml"); + let first = WatchSnapshotSender::load(journal.clone()).prepare( + "paired-watch", + vec![entry("otp/alice", "Acme", "alice", b"secret")], + )?; + assert_eq!(first.revision(), 1); + + let same = WatchSnapshotSender::load(journal.clone()).prepare( + "paired-watch", + vec![entry("otp/alice", "Acme", "alice", b"secret")], + )?; + assert_eq!(same.revision(), 1); + let changed = WatchSnapshotSender::load(journal).prepare("paired-watch", Vec::new())?; + assert_eq!(changed.revision(), 2); + Ok(()) +}