diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index eb927124a..0133251f0 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1182,7 +1182,7 @@ repositoryURL = "https://github.com/pubky/paykit-rs"; requirement = { kind = exactVersion; - version = "0.1.0-rc46"; + version = "0.1.0-rc51"; }; }; 18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 2b1adf6fb..5e8860816 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pubky/paykit-rs", "state" : { - "revision" : "09e388d82f70d02be9860b8b6ac108ae722a3fb9", - "version" : "0.1.0-rc46" + "revision" : "80f3d81898ab23279134797e5625cae7232dad15", + "version" : "0.1.0-rc51" } }, { diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index ab8f05a7f..615814c74 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -933,11 +933,15 @@ struct AppScene: View { private func retryPendingPaykitEndpointRemoval() async { if PublicPaykitService.isCleanupPending { do { - if UserDefaults.standard.bool(forKey: PublicPaykitService.publishingEnabledKey) { + switch PublicPaykitService.pendingReconciliationMode() { + case .publishEndpoints: try await PublicPaykitService.syncCurrentPublishedEndpoints(wallet: wallet) - } else { + case .removePublishedState: try await PublicPaykitService.removePublishedEndpoints() - try await PublicPaykitService.syncLocalReceiverMarker(publicSharingEnabled: false) + try await PublicPaykitService.syncLocalReceiverMarker( + publicSharingEnabled: false, + privateSharingEnabled: false + ) } PublicPaykitService.setCleanupPending(false) } catch { @@ -945,7 +949,7 @@ struct AppScene: View { } } - await PrivatePaykitService.shared.retryPendingEndpointRemoval( + await PrivatePaykitService.shared.retryPendingEndpointReconciliation( wallet: wallet, savedPublicKeys: contactsManager.contacts.map(\.publicKey) ) diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index b3da17c3d..efb34e4d8 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -309,10 +309,9 @@ class PubkyProfileManager: ObservableObject { profile = createdProfile cacheProfileMetadata(createdProfile) } catch { - try? Keychain.delete(key: .pubkySecretKey) - try? Keychain.delete(key: .paykitSession) - await PubkyService.forceSignOut() - throw error + let profileCreationError = error + await discardAbandonedSession() + throw profileCreationError } Logger.info("Pubky identity created for \(publicKeyZ32)", context: "PubkyProfileManager") @@ -365,6 +364,7 @@ class PubkyProfileManager: ObservableObject { Logger.info("Bitkit profile storage already missing, continuing sign out", context: "PubkyProfileManager") } + Self.clearPaykitSharingAfterProfileDeletion() try await signOut(cleanPrivatePaykitEndpoints: false) } @@ -507,7 +507,9 @@ class PubkyProfileManager: ObservableObject { try await completeAuthentication( completeAuth: { _ = try await PubkyService.completeAuth() }, currentPublicKey: { await PubkyService.currentPublicKey() }, - clearSessionAccess: { await PubkyService.clearSessionAccess() } + discardSessionAccess: { + await self.discardAbandonedSession() + } ) } @@ -515,7 +517,7 @@ class PubkyProfileManager: ObservableObject { private func completeAuthentication( completeAuth: @escaping () async throws -> Void, currentPublicKey: @escaping () async -> String?, - clearSessionAccess: @escaping () async -> Void + discardSessionAccess: @escaping () async -> Void ) async throws -> String { guard let attemptID = activeAuthAttemptID else { throw CancellationError() @@ -523,8 +525,8 @@ class PubkyProfileManager: ObservableObject { var didCompleteAuth = false do { - try await completeAuth() didCompleteAuth = true + try await completeAuth() try Task.checkCancellation() guard activeAuthAttemptID == attemptID else { throw CancellationError() @@ -548,14 +550,20 @@ class PubkyProfileManager: ObservableObject { await loadProfile() return pk } catch is CancellationError { - await clearCompletedAuthSessionIfNeeded(didCompleteAuth, clearSessionAccess: clearSessionAccess) + await discardCompletedAuthSessionIfNeeded( + didCompleteAuth, + discardSessionAccess: discardSessionAccess + ) if activeAuthAttemptID == attemptID { activeAuthAttemptID = nil restoreAuthStateAfterAuthFlow() } throw CancellationError() } catch let serviceError as PubkyServiceError { - await clearCompletedAuthSessionIfNeeded(didCompleteAuth, clearSessionAccess: clearSessionAccess) + await discardCompletedAuthSessionIfNeeded( + didCompleteAuth, + discardSessionAccess: discardSessionAccess + ) guard activeAuthAttemptID == attemptID else { throw CancellationError() } @@ -564,7 +572,10 @@ class PubkyProfileManager: ObservableObject { restoreAuthStateAfterAuthFlow() throw serviceError } catch { - await clearCompletedAuthSessionIfNeeded(didCompleteAuth, clearSessionAccess: clearSessionAccess) + await discardCompletedAuthSessionIfNeeded( + didCompleteAuth, + discardSessionAccess: discardSessionAccess + ) guard activeAuthAttemptID == attemptID else { throw CancellationError() } @@ -575,9 +586,43 @@ class PubkyProfileManager: ObservableObject { } } - private func clearCompletedAuthSessionIfNeeded(_ didCompleteAuth: Bool, clearSessionAccess: @escaping () async -> Void) async { + private func discardCompletedAuthSessionIfNeeded( + _ didCompleteAuth: Bool, + discardSessionAccess: @escaping () async -> Void + ) async { guard didCompleteAuth else { return } - await clearSessionAccess() + await discardSessionAccess() + } + + private func discardAbandonedSession() async { + await discardAbandonedSession( + revokeSessionAccess: { + try await Task.detached { + try await PubkyService.signOut() + }.value + }, + forgetSessionAccess: { + try await Task.detached { + try await PubkyService.forgetSessionAccess() + }.value + } + ) + } + + private func discardAbandonedSession( + revokeSessionAccess: @escaping () async throws -> Void, + forgetSessionAccess: @escaping () async throws -> Void + ) async { + do { + try await revokeSessionAccess() + } catch { + Logger.warn("Failed to revoke abandoned Pubky session: \(error)", context: "PubkyProfileManager") + do { + try await forgetSessionAccess() + } catch { + Logger.warn("Failed to forget abandoned Pubky session access: \(error)", context: "PubkyProfileManager") + } + } } func finalizeAuthentication() { @@ -614,12 +659,22 @@ class PubkyProfileManager: ObservableObject { func completeAuthenticationForTesting( completeAuth: @escaping () async throws -> Void, currentPublicKey: @escaping () async -> String?, - clearSessionAccess: @escaping () async -> Void + discardSessionAccess: @escaping () async -> Void ) async throws -> String { try await completeAuthentication( completeAuth: completeAuth, currentPublicKey: currentPublicKey, - clearSessionAccess: clearSessionAccess + discardSessionAccess: discardSessionAccess + ) + } + + func discardAbandonedSessionForTesting( + revokeSessionAccess: @escaping () async throws -> Void, + forgetSessionAccess: @escaping () async throws -> Void + ) async { + await discardAbandonedSession( + revokeSessionAccess: revokeSessionAccess, + forgetSessionAccess: forgetSessionAccess ) } #endif @@ -666,11 +721,17 @@ class PubkyProfileManager: ObservableObject { // MARK: - Sign Out static func clearLocalState() async { + do { + try await PubkyService.forgetSessionAccess() + } catch { + Logger.warn("Failed to forget local Pubky session access: \(error)", context: "PubkyProfileManager") + } + await clearLocalAppState() + } + + private static func clearLocalAppState() async { await PrivatePaykitService.shared.closeAndClear() await PrivatePaykitAddressReservationStore.shared.clearContactAssignments() - await PubkyService.forceSignOut() - try? Keychain.delete(key: .paykitSession) - try? Keychain.delete(key: .pubkySecretKey) await PubkyImageCache.shared.clear() UserDefaults.standard.removeObject(forKey: cachedNameKey) UserDefaults.standard.removeObject(forKey: cachedImageUriKey) @@ -747,22 +808,56 @@ class PubkyProfileManager: ObservableObject { } private func signOut(cleanPrivatePaykitEndpoints: Bool) async throws { - try await Task.detached { - if cleanPrivatePaykitEndpoints { - try await Self.removePrivatePaykitEndpoints(context: "PubkyProfileManager.signOut") - } - await Self.removePublicPaykitEndpointsBestEffort(context: "PubkyProfileManager.signOut") - do { + let publicSharingEnabled = UserDefaults.standard.bool(forKey: PublicPaykitService.publishingEnabledKey) + let privateSharingEnabled = UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey) + + do { + try await Task.detached { + if cleanPrivatePaykitEndpoints { + try await Self.removePrivatePaykitEndpoints(context: "PubkyProfileManager.signOut") + } + await Self.removePublicPaykitEndpointsBestEffort(context: "PubkyProfileManager.signOut") try await PubkyService.signOut() - } catch { - Logger.warn("Server sign out failed, forcing local sign out: \(error)", context: "PubkyProfileManager") - } - await Self.clearLocalState() - }.value + await Self.clearLocalAppState() + }.value + } catch { + Self.markPaykitReconciliationPendingAfterFailedSignOut( + publicSharingEnabled: publicSharingEnabled, + privateSharingEnabled: privateSharingEnabled + ) + throw error + } clearAuthenticatedState() } + static func markPaykitReconciliationPendingAfterFailedSignOut( + publicSharingEnabled: Bool, + privateSharingEnabled: Bool, + setPublicReconciliationPending: (Bool) -> Void = PublicPaykitService.setCleanupPending, + setPrivateReconciliationPending: (Bool) -> Void = PrivatePaykitService.setContactSharingCleanupPending + ) { + if publicSharingEnabled || privateSharingEnabled { + setPublicReconciliationPending(true) + } + if privateSharingEnabled { + setPrivateReconciliationPending(true) + } + } + + static func clearPaykitSharingAfterProfileDeletion( + defaults: UserDefaults = .standard, + setPublicReconciliationPending: (Bool) -> Void = PublicPaykitService.setCleanupPending + ) { + let hadPublishedState = defaults.bool(forKey: PublicPaykitService.publishingEnabledKey) || + defaults.bool(forKey: PrivatePaykitService.publishingEnabledKey) + defaults.set(false, forKey: PublicPaykitService.publishingEnabledKey) + defaults.set(false, forKey: PrivatePaykitService.publishingEnabledKey) + if hadPublishedState { + setPublicReconciliationPending(true) + } + } + func refreshSessionIfPossible(after error: Error) async -> Bool { await Self.refreshSessionIfPossible( after: error, @@ -871,8 +966,8 @@ class PubkyProfileManager: ObservableObject { deleteKeychainValue: (KeychainEntryType) throws -> Void = { try Keychain.delete(key: $0) }, - clearSessionAccess: @escaping () async -> Void = { - await PubkyService.clearSessionAccess() + forgetSessionAccess: @escaping () async throws -> Void = { + try await PubkyService.forgetSessionAccess() }, signInWithSecretKey: @escaping (String) async throws -> String = { try await PubkyService.signIn(secretKeyHex: $0) @@ -881,7 +976,11 @@ class PubkyProfileManager: ObservableObject { try await PubkyService.importExternalSession(secret: $0) } ) async throws { - await clearSessionAccess() + do { + try await forgetSessionAccess() + } catch { + Logger.warn("Failed to forget existing Pubky session before restore: \(error)", context: "PubkyProfileManager") + } switch backup?.kind { case .none: diff --git a/Bitkit/Models/PubkyAuthRequest.swift b/Bitkit/Models/PubkyAuthRequest.swift index 8b06f15c1..4a785ca16 100644 --- a/Bitkit/Models/PubkyAuthRequest.swift +++ b/Bitkit/Models/PubkyAuthRequest.swift @@ -53,6 +53,7 @@ struct PubkyAuthPermission { struct PubkyAuthRequest { let rawUrl: String let kind: Paykit.PubkyAuthRequestKind + let clientID: String let relay: String let capabilities: String let permissions: [PubkyAuthPermission] @@ -75,6 +76,7 @@ struct PubkyAuthRequest { return PubkyAuthRequest( rawUrl: url, kind: details.kind, + clientID: details.clientId, relay: details.relayUrl ?? "", capabilities: capabilities, permissions: permissions, diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 634feaa4b..7fe9ba826 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -693,6 +693,7 @@ "pubky_auth__title" = "Authorize"; "pubky_auth__description_prefix" = "A service is requesting permission to access and edit your "; "pubky_auth__description_suffix" = " data."; +"pubky_auth__requester" = "Requester ID: {clientId}"; "pubky_auth__requested_permissions" = "REQUESTED PERMISSIONS"; "pubky_auth__watch_only_account_default_name" = "{service} account"; "pubky_auth__watch_only_account_fallback_name" = "Paykit server account"; diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index 0e0c2d151..d76b11cf7 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -4,6 +4,15 @@ import Paykit // MARK: - Saved Contacts extension PrivatePaykitService { + enum FullCleanupReconciliationMode: Equatable { + case restoreSavedContacts + case removePublishedState + } + + static func fullCleanupReconciliationMode(defaults: UserDefaults = .standard) -> FullCleanupReconciliationMode { + return defaults.bool(forKey: publishingEnabledKey) ? .restoreSavedContacts : .removePublishedState + } + @discardableResult func prepareSavedContacts( _ publicKeys: [String], @@ -255,9 +264,28 @@ extension PrivatePaykitService { } } - func retryPendingEndpointRemoval(wallet _: WalletViewModel, savedPublicKeys publicKeys: [String]) async { + func retryPendingEndpointReconciliation(wallet: WalletViewModel, savedPublicKeys publicKeys: [String]) async { let savedKeys = Set(normalizedSavedContactKeys(publicKeys)) let isFullCleanupPending = UserDefaults.standard.bool(forKey: Self.cleanupPendingKey) + if isFullCleanupPending, + Self.fullCleanupReconciliationMode() == .restoreSavedContacts + { + let restoreKeys = savedKeys.union(knownSavedContactKeys) + guard !restoreKeys.isEmpty else { return } + + let error = await prepareSavedContacts( + Array(restoreKeys), + wallet: wallet, + requireImmediatePublication: true + ) + if let error { + Logger.warn("Failed to reconcile private Paykit endpoints: \(error)", context: "PrivatePaykit") + } else { + Self.setContactSharingCleanupPending(false) + } + return + } + let cleanupKeys = isFullCleanupPending ? Set(knownSavedContactKeys).union(state.contacts.keys).union(Self.pendingDeletedContactCleanupKeys()) : Set(pendingPrivateEndpointRemovalKeys(savedPublicKeys: publicKeys)) diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 4b5045f44..9ec7a00ac 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -89,18 +89,25 @@ enum PubkyService { } /// Approve a pubkyauth:// request using the local secret key. - static func approveAuth(authUrl: String, expectedCapabilities: String, secretKeyHex: String) async throws { + static func approveAuth(authUrl: String, expectedCapabilities: String, approvedClientID: String, secretKeyHex: String) async throws { try await PaykitSdkService.shared.approveAuth( authUrl: authUrl, expectedCapabilities: expectedCapabilities, + approvedClientID: approvedClientID, secretKeyHex: secretKeyHex ) } - static func approveAuthWithCompanionClaim(authUrl: String, unsignedPayload: Data, secretKeyHex: String) async throws { + static func approveAuthWithCompanionClaim( + authUrl: String, + approvedClientID: String, + unsignedPayload: Data, + secretKeyHex: String + ) async throws { try await PaykitSdkService.shared.approveAuthWithCompanionClaim( authUrl: authUrl, expectedCapabilities: PubkyAuthClaim.watchOnlyAccountCapabilities, + approvedClientID: approvedClientID, secretKeyHex: secretKeyHex, claim: Paykit.PubkyAuthCompanionClaim( queryParameter: PubkyAuthClaim.queryParameter, @@ -119,8 +126,8 @@ enum PubkyService { return false } - typealias OrdinaryAuthApproval = (String, String, String) async throws -> Void - typealias CompanionAuthApproval = (String, Data, String) async throws -> Void + typealias OrdinaryAuthApproval = (String, String, String, String) async throws -> Void + typealias CompanionAuthApproval = (String, String, Data, String) async throws -> Void @MainActor static func approveAuthRequest( @@ -129,16 +136,18 @@ enum PubkyService { accountName: String, secretKeyHex: String, accountManager: WatchOnlyAccountManager? = nil, - ordinaryApproval: @escaping OrdinaryAuthApproval = { authUrl, capabilities, secretKeyHex in + ordinaryApproval: @escaping OrdinaryAuthApproval = { authUrl, capabilities, clientID, secretKeyHex in try await approveAuth( authUrl: authUrl, expectedCapabilities: capabilities, + approvedClientID: clientID, secretKeyHex: secretKeyHex ) }, - companionApproval: @escaping CompanionAuthApproval = { authUrl, unsignedPayload, secretKeyHex in + companionApproval: @escaping CompanionAuthApproval = { authUrl, clientID, unsignedPayload, secretKeyHex in try await approveAuthWithCompanionClaim( authUrl: authUrl, + approvedClientID: clientID, unsignedPayload: unsignedPayload, secretKeyHex: secretKeyHex ) @@ -161,7 +170,7 @@ enum PubkyService { } do { - try await companionApproval(authUrl, preparedClaim.1, secretKeyHex) + try await companionApproval(authUrl, request.clientID, preparedClaim.1, secretKeyHex) } catch { if !didDeliverCompanionClaim(error: error) { await cancelIncompleteAuthorization( @@ -174,7 +183,7 @@ enum PubkyService { try await accountManager.markSetupActive(attempt: authorizationAttempt) } else { - try await ordinaryApproval(authUrl, request.capabilities, secretKeyHex) + try await ordinaryApproval(authUrl, request.capabilities, request.clientID, secretKeyHex) } } @@ -274,18 +283,16 @@ enum PubkyService { try await PaykitSdkService.shared.signOut() } - static func forceSignOut() async { - await PaykitSdkService.shared.forceSignOut() - } - - static func clearSessionAccess() async { - await PaykitSdkService.shared.clearSessionAccess() + static func forgetSessionAccess() async throws { + try await PaykitSdkService.shared.forgetSessionAccess() } } // MARK: - Paykit SDK Runtime actor PaykitSdkService { + typealias ApprovalBootstrapFactory = (String, PubkyClientConfig) throws -> PubkySessionBootstrap + static let shared = PaykitSdkService() private static let walletBackupDataChangedSubject = PassthroughSubject() @@ -298,10 +305,17 @@ actor PaykitSdkService { private let paymentAdapter = PaykitSdkPaymentAdapter() private let operationLock = PaykitSdkOperationLock() private let pubkyClientConfig = PaykitSdkService.makePubkyClientConfig(localTestnetHost: Env.pubkyLocalTestnetHost) + private let approvalBootstrapFactory: ApprovalBootstrapFactory private var sdk: PaykitSdk? private var activeAuthRequest: Paykit.PubkyAuthRequest? private var activeAuthRequestID: UUID? + init( + approvalBootstrapFactory: @escaping ApprovalBootstrapFactory = PubkySessionBootstrap.withPubkyClientConfig(clientId:pubkyClient:) + ) { + self.approvalBootstrapFactory = approvalBootstrapFactory + } + func initialize() async throws { try await operationLock.withLock { var sdk = try handle() @@ -445,9 +459,9 @@ actor PaykitSdkService { activeAuthRequestID = nil } - func approveAuth(authUrl: String, expectedCapabilities: String, secretKeyHex: String) async throws { + func approveAuth(authUrl: String, expectedCapabilities: String, approvedClientID: String, secretKeyHex: String) async throws { try await operationLock.withLock { - try await bootstrap().approveAuth( + try await approvalBootstrap(authUrl: authUrl, approvedClientID: approvedClientID).approveAuth( authUrl: authUrl, expectedCapabilities: expectedCapabilities, localSecretKey: Self.localSecretKey(fromHex: secretKeyHex) @@ -458,11 +472,12 @@ actor PaykitSdkService { func approveAuthWithCompanionClaim( authUrl: String, expectedCapabilities: String, + approvedClientID: String, secretKeyHex: String, claim: Paykit.PubkyAuthCompanionClaim ) async throws { try await operationLock.withLock { - try await bootstrap().approveAuthWithCompanionClaim( + try await approvalBootstrap(authUrl: authUrl, approvedClientID: approvedClientID).approveAuthWithCompanionClaim( authUrl: authUrl, expectedCapabilities: expectedCapabilities, localSecretKey: Self.localSecretKey(fromHex: secretKeyHex), @@ -817,24 +832,12 @@ actor PaykitSdkService { resetRuntime() } - func forceSignOut() async { - await operationLock.withLock { - sessionProvider.clearLiveSessionAccess() - try? Keychain.delete(key: .paykitSession) - try? Keychain.delete(key: .pubkySecretKey) - clearStateLocked() - } - } - - func clearSessionAccess() async { - await operationLock.withLock { - sessionProvider.clearLiveSessionAccess() - try? Keychain.delete(key: .paykitSession) - try? Keychain.delete(key: .pubkySecretKey) + func forgetSessionAccess() async throws { + defer { resetRuntime() } + try await withStateRevisionTracking { sdk in activeAuthRequest = nil activeAuthRequestID = nil - resetRuntime() - markWalletBackupDataChanged() + _ = try await sdk.forgetSessionAccess() } } @@ -1016,7 +1019,7 @@ actor PaykitSdkService { return false } - return context == "import Pubky session from platform provider" + return context == "restore Pubky grant session from platform provider" } private nonisolated static func canReceivePrivatePaymentDetails(marker: Paykit.PaykitReceiverMarker?) -> Bool { @@ -1024,7 +1027,21 @@ actor PaykitSdkService { } private func bootstrap() throws -> PubkySessionBootstrap { - try PubkySessionBootstrap.withPubkyClientConfig(pubkyClient: pubkyClientConfig) + try PubkySessionBootstrap.withPubkyClientConfig( + clientId: Self.clientID, + pubkyClient: pubkyClientConfig + ) + } + + func approvalBootstrap(authUrl: String, approvedClientID: String) throws -> PubkySessionBootstrap { + let requestClientID = try Paykit.parsePubkyAuthUrl(authUrl: authUrl).clientId + guard !approvedClientID.isEmpty, approvedClientID == requestClientID else { + throw AppError( + message: "pubky_auth__invalid_request", + debugMessage: "Approved Pubky client ID does not match auth request" + ) + } + return try approvalBootstrapFactory(requestClientID, pubkyClientConfig) } nonisolated static func makePubkyClientConfig(localTestnetHost: String?) -> PubkyClientConfig { @@ -1035,15 +1052,19 @@ actor PaykitSdkService { private nonisolated static func config() throws -> PaykitSdkConfig { var config = try Paykit.defaultConfig(receiverPath: PaykitReceiverPath.wallet) - config.profileNamespace = switch Env.network { - case .bitcoin: "bitkit.to" - default: "staging.bitkit.to" - } + config.profileNamespace = clientID config.endpointManagementScope = .managedOnly config.encryptedLinkRecoveryMarkers = .enabled config.publicContactSharing = .localOnly return config } + + nonisolated static var clientID: String { + switch Env.network { + case .bitcoin: "bitkit.to" + default: "staging.bitkit.to" + } + } } private final class PaykitSdkOperationLock: @unchecked Sendable { @@ -1165,6 +1186,7 @@ private final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecke } return try PubkySessionAccess( + clientId: PaykitSdkService.clientID, sessionSecret: sessionSecret, localSecretKey: loadLocalSecretKey(), receiverNoiseSecretKey: loadOrDeriveReceiverNoiseSecretKey() @@ -1195,8 +1217,7 @@ private final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecke func clearSessionAccess() throws { clearLiveSessionAccess() - try? Keychain.delete(key: .paykitSession) - try? Keychain.delete(key: .pubkySecretKey) + try PubkySessionAccessTeardown.clear { try Keychain.delete(key: $0) } } func loadLocalSecretKey() throws -> PubkyLocalSecretKey? { @@ -1216,6 +1237,22 @@ private final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecke } } +enum PubkySessionAccessTeardown { + static func clear(deleteKeychainValue: (KeychainEntryType) throws -> Void) throws { + var firstError: Error? + for key in [KeychainEntryType.paykitSession, .pubkySecretKey] { + do { + try deleteKeychainValue(key) + } catch { + firstError = firstError ?? error + } + } + if let firstError { + throw firstError + } + } +} + enum PaykitReceiverNoiseKeyDerivation { private static let domain = "bitkit/paykit/receiver-noise-key" private static let version = "v1" diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift index 72274ba5b..c0bc66173 100644 --- a/Bitkit/Services/PublicPaykitService.swift +++ b/Bitkit/Services/PublicPaykitService.swift @@ -95,6 +95,18 @@ enum PublicPaykitService { UserDefaults.standard.bool(forKey: cleanupPendingKey) } + enum PendingReconciliationMode: Equatable { + case publishEndpoints + case removePublishedState + } + + static func pendingReconciliationMode(defaults: UserDefaults = .standard) -> PendingReconciliationMode { + if defaults.bool(forKey: publishingEnabledKey) { + return .publishEndpoints + } + return .removePublishedState + } + enum MethodId: String, Hashable, CaseIterable { case bitcoinLightningBolt11 = "btc-lightning-bolt11" case bitcoinLightningLnurl = "btc-lightning-lnurl" diff --git a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift index 2d79b2076..c7842ee46 100644 --- a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift +++ b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift @@ -229,6 +229,11 @@ struct PubkyAuthApprovalSheet: View { ScrollView { VStack(alignment: .leading, spacing: 0) { descriptionText + .padding(.bottom, 8) + + BodySText(t("pubky_auth__requester", variables: ["clientId": config.request.clientID])) + .lineLimit(1) + .truncationMode(.tail) .padding(.bottom, 32) permissionsSection diff --git a/BitkitTests/PaykitSdkClientConfigTests.swift b/BitkitTests/PaykitSdkClientConfigTests.swift index c1cca72c7..4a3f5b978 100644 --- a/BitkitTests/PaykitSdkClientConfigTests.swift +++ b/BitkitTests/PaykitSdkClientConfigTests.swift @@ -3,6 +3,17 @@ import Paykit import XCTest final class PaykitSdkClientConfigTests: XCTestCase { + private let externalAuthURL = + "pubkyauth://signin_grant?caps=/pub/example/:rw&relay=https://httprelay.pubky.app/inbox/" + + "&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s" + + "&cid=paykit.test&cpk=5jsjx1o6fzu6aeeo697r3i5rx15zq41kikcye8wtwdqm4nb4tryo" + + func testClientIDUsesBitkitOwnedDomain() { + let expectedClientID = Env.network == .bitcoin ? "bitkit.to" : "staging.bitkit.to" + + XCTAssertEqual(PaykitSdkService.clientID, expectedClientID) + } + func testProductionUsesDefaultPubkyClient() { let config = PaykitSdkService.makePubkyClientConfig(localTestnetHost: nil) @@ -15,17 +26,68 @@ final class PaykitSdkClientConfigTests: XCTestCase { XCTAssertEqual(config.localTestnetHost, "192.0.2.1") } + func testApprovalBootstrapUsesExternalRequesterClientID() async throws { + var configuredClientID: String? + let service = PaykitSdkService { clientID, _ in + configuredClientID = clientID + return PubkySessionBootstrap(noPointer: .init()) + } + + _ = try await service.approvalBootstrap( + authUrl: externalAuthURL, + approvedClientID: "paykit.test" + ) + + XCTAssertEqual(configuredClientID, "paykit.test") + } + + func testApprovalBootstrapRejectsMismatchedClientID() async { + var didCreateBootstrap = false + let service = PaykitSdkService { _, _ in + didCreateBootstrap = true + return PubkySessionBootstrap(noPointer: .init()) + } + + do { + _ = try await service.approvalBootstrap( + authUrl: externalAuthURL, + approvedClientID: "different.test" + ) + XCTFail("Expected a mismatched client ID to be rejected") + } catch {} + + XCTAssertFalse(didCreateBootstrap) + } + func testStoredSessionCanBeDeferredDuringSdkInitialization() { - let error = PaykitError.Identity(code: "identity_error", context: "import Pubky session from platform provider") + let error = PaykitError.Identity(code: "identity_error", context: "restore Pubky grant session from platform provider") XCTAssertTrue(PaykitSdkService.shouldDeferStaleSession(error: error, hasStoredSession: true)) } func testMissingSessionOrUnrelatedIdentityFailureIsNotDeferred() { - let staleSession = PaykitError.Identity(code: "identity_error", context: "import Pubky session from platform provider") + let staleSession = PaykitError.Identity(code: "identity_error", context: "restore Pubky grant session from platform provider") let unrelatedError = PaykitError.Identity(code: "identity_error", context: "local Pubky secret key does not match session public key") XCTAssertFalse(PaykitSdkService.shouldDeferStaleSession(error: staleSession, hasStoredSession: false)) XCTAssertFalse(PaykitSdkService.shouldDeferStaleSession(error: unrelatedError, hasStoredSession: true)) } + + func testSessionAccessTeardownAttemptsBothCredentialsWithSessionFirst() { + var attemptedKeys: [String] = [] + + XCTAssertThrowsError( + try PubkySessionAccessTeardown.clear { key in + attemptedKeys.append(key.storageKey) + if key.storageKey == KeychainEntryType.paykitSession.storageKey { + throw KeychainError.failedToDelete + } + } + ) + + XCTAssertEqual( + attemptedKeys, + [KeychainEntryType.paykitSession.storageKey, KeychainEntryType.pubkySecretKey.storageKey] + ) + } } diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index fb8407358..7b536082b 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -17,6 +17,53 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertTrue(PrivatePaykitService.initialLinkBurstRetryDelays.allSatisfy { $0 == 2_000_000_000 }) } + func testPendingEndpointReconciliationRestoresSavedContactsWhenPublishingRemainsEnabled() throws { + try withIsolatedDefaults { defaults in + defaults.set(true, forKey: PrivatePaykitService.cleanupPendingKey) + defaults.set(true, forKey: PrivatePaykitService.publishingEnabledKey) + + XCTAssertEqual( + PrivatePaykitService.fullCleanupReconciliationMode(defaults: defaults), + .restoreSavedContacts + ) + } + } + + func testPendingEndpointReconciliationRemovesPublishedStateWhenPublishingIsDisabled() throws { + try withIsolatedDefaults { defaults in + defaults.set(true, forKey: PrivatePaykitService.cleanupPendingKey) + defaults.set(false, forKey: PrivatePaykitService.publishingEnabledKey) + + XCTAssertEqual( + PrivatePaykitService.fullCleanupReconciliationMode(defaults: defaults), + .removePublishedState + ) + } + } + + @MainActor + func testPendingEndpointReconciliationKeepsKnownContactsWhenLoadedListIsEmpty() async { + let defaults = UserDefaults.standard + let previousCleanupPending = defaults.object(forKey: PrivatePaykitService.cleanupPendingKey) + let previousPublishingEnabled = defaults.object(forKey: PrivatePaykitService.publishingEnabledKey) + defer { + defaults.set(previousCleanupPending, forKey: PrivatePaykitService.cleanupPendingKey) + defaults.set(previousPublishingEnabled, forKey: PrivatePaykitService.publishingEnabledKey) + } + + defaults.set(true, forKey: PrivatePaykitService.cleanupPendingKey) + defaults.set(true, forKey: PrivatePaykitService.publishingEnabledKey) + let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + let service = PrivatePaykitService() + _ = await service.rememberSavedContacts([publicKey], replacing: true) + + await service.retryPendingEndpointReconciliation(wallet: WalletViewModel(), savedPublicKeys: []) + + let knownSavedContactKeys = await service.knownSavedContactKeys + XCTAssertEqual(knownSavedContactKeys, [publicKey]) + XCTAssertTrue(defaults.bool(forKey: PrivatePaykitService.cleanupPendingKey)) + } + func testReceiverNoiseDerivationMatchesCrossPlatformVector() { let seed = ( "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e534955" + @@ -32,6 +79,14 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertEqual(key.hex, "500f4799bbb2d02103e3b74b365ddb478a3187333c053fa9eb62f4052ba6a327") } + private func withIsolatedDefaults(_ body: (UserDefaults) throws -> Void) throws { + let suiteName = "PrivatePaykitServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + try body(defaults) + } + func testDuplicatePaymentErrorClassificationUsesWrappedAppErrorReason() { XCTAssertTrue( PrivatePaykitService.isDuplicatePaymentError( diff --git a/BitkitTests/PubkyAuthApprovalSheetTests.swift b/BitkitTests/PubkyAuthApprovalSheetTests.swift index cc1d17dc9..f1d6b83e5 100644 --- a/BitkitTests/PubkyAuthApprovalSheetTests.swift +++ b/BitkitTests/PubkyAuthApprovalSheetTests.swift @@ -6,13 +6,21 @@ import XCTest private let approvalTestXpub = "tpubDDWohsp5dx2iMJ9N7iHbgAEDhH4BJB9NWW1fEW3yA3AFNDREmpzteCXNqppMLUmKFY5q5e3" + "PXtS5CuqWCQbYcGhpPqYAgQSYdwknW9J6sQv" +private let approvalTestClientPublicKey = "5jsjx1o6fzu6aeeo697r3i5rx15zq41kikcye8wtwdqm4nb4tryo" private func approvalTestAuthUrl(secret: String = "e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s") -> String { - "pubkyauth://signin?caps=\(PubkyAuthClaim.watchOnlyAccountCapabilities)" + + "pubkyauth://signin_grant?caps=\(PubkyAuthClaim.watchOnlyAccountCapabilities)" + "&relay=https://httprelay.pubky.app/inbox/&secret=\(secret)" + + "&cid=paykit.test&cpk=\(approvalTestClientPublicKey)" + "&x-bitkit-claim=watch-only-account-v1" } +private func ordinaryApprovalTestAuthUrl() -> String { + "pubkyauth://signin_grant?caps=/pub/example/:rw&relay=https://httprelay.pubky.app/inbox/" + + "&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s" + + "&cid=paykit.test&cpk=\(approvalTestClientPublicKey)" +} + final class PubkyAuthApprovalSheetTests: XCTestCase { func testAuthDisplayPublicKeyOmitsPubkyPrefix() { XCTAssertEqual(pubkyAuthDisplayPublicKey("pubky3rsd123456789w5xg"), "3rsd...w5xg") @@ -33,7 +41,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { } func testOrdinaryRequestStartsAtNormalAuthorization() throws { - let authUrl = "pubkyauth://signin?caps=/pub/example/:rw&relay=https://httprelay.pubky.app/inbox/&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s" + let authUrl = ordinaryApprovalTestAuthUrl() let request = try PubkyAuthRequest.parse(url: authUrl) XCTAssertEqual(PubkyAuthApprovalSheet.initialState(for: request), .authorize) @@ -41,20 +49,25 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { @MainActor func testOrdinaryRequestUsesOrdinaryApproval() async throws { - let authUrl = "pubkyauth://signin?caps=/pub/example/:rw&relay=https://httprelay.pubky.app/inbox/&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s" + let authUrl = ordinaryApprovalTestAuthUrl() let request = try PubkyAuthRequest.parse(url: authUrl) var approvedCapabilities: String? + var approvedClientID: String? try await PubkyService.approveAuthRequest( request: request, authUrl: authUrl, accountName: "", secretKeyHex: "secret", - ordinaryApproval: { _, capabilities, _ in approvedCapabilities = capabilities }, - companionApproval: { _, _, _ in XCTFail("Ordinary auth must not deliver a companion claim") } + ordinaryApproval: { _, capabilities, clientID, _ in + approvedCapabilities = capabilities + approvedClientID = clientID + }, + companionApproval: { _, _, _, _ in XCTFail("Ordinary auth must not deliver a companion claim") } ) XCTAssertEqual(approvedCapabilities, "/pub/example/:rw") + XCTAssertEqual(approvedClientID, "paykit.test") } func testResolvePubkyApprovalLocalAuthModePrefersPinWhenPinEnabled() { @@ -117,8 +130,8 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { accountName: "Creator store", secretKeyHex: "secret", accountManager: manager, - ordinaryApproval: { _, _, _ in ordinaryApprovalCount += 1 }, - companionApproval: { _, _, _ in + ordinaryApproval: { _, _, _, _ in ordinaryApprovalCount += 1 }, + companionApproval: { _, _, _, _ in companionApprovalCount += 1 throw ApprovalFakeError.deliveryFailed } @@ -151,7 +164,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { accountName: "Creator store", secretKeyHex: "secret", accountManager: manager, - companionApproval: { _, _, _ in } + companionApproval: { _, _, _, _ in } ) XCTAssertEqual(manager.accounts.first?.setupState, .active) @@ -188,7 +201,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { accountName: "First account", secretKeyHex: "secret", accountManager: manager, - companionApproval: { _, _, _ in await companionApprovalGate.approve() } + companionApproval: { _, _, _, _ in await companionApprovalGate.approve() } ) } try await companionApprovalGate.waitUntilFirstApprovalStarts() @@ -201,7 +214,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { accountName: "Replacement account", secretKeyHex: "secret", accountManager: manager, - companionApproval: { _, _, _ in XCTFail("Concurrent companion approval must not start") } + companionApproval: { _, _, _, _ in XCTFail("Concurrent companion approval must not start") } ) XCTFail("Expected concurrent authorization to be rejected") } catch { @@ -222,7 +235,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { accountName: "Second account", secretKeyHex: "secret", accountManager: manager, - companionApproval: { _, _, _ in } + companionApproval: { _, _, _, _ in } ) XCTAssertEqual(manager.accounts.map(\.setupState), [.active, .active]) @@ -247,7 +260,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { accountName: "Creator store", secretKeyHex: "secret", accountManager: manager, - companionApproval: { _, _, _ in + companionApproval: { _, _, _, _ in throw Paykit.PubkyAuthCompanionClaimApprovalError.AuthorizationFailure(reason: "normal auth failed") } ) @@ -276,7 +289,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { accountName: "Creator store", secretKeyHex: "secret", accountManager: manager, - companionApproval: { _, _, _ in + companionApproval: { _, _, _, _ in throw Paykit.PubkyAuthCompanionClaimApprovalError.AuthorizationFailure(reason: "normal auth failed") } ) @@ -289,7 +302,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { accountName: "Creator store", secretKeyHex: "secret", accountManager: manager, - companionApproval: { _, _, _ in throw ApprovalFakeError.deliveryFailed } + companionApproval: { _, _, _, _ in throw ApprovalFakeError.deliveryFailed } ) } @@ -317,7 +330,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { accountName: "Creator store", secretKeyHex: "secret", accountManager: initialManager, - companionApproval: { _, payload, _ in + companionApproval: { _, _, payload, _ in deliveredPayloads.append(payload) throw Paykit.PubkyAuthCompanionClaimApprovalError.AuthorizationFailure(reason: "normal auth failed") } @@ -335,7 +348,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { accountName: "Creator store", secretKeyHex: "secret", accountManager: restartedManager, - companionApproval: { _, payload, _ in deliveredPayloads.append(payload) } + companionApproval: { _, _, payload, _ in deliveredPayloads.append(payload) } ) let activeAccount = try XCTUnwrap(restartedManager.accounts.first) @@ -369,7 +382,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { accountName: "Creator store", secretKeyHex: "secret", accountManager: manager, - companionApproval: { _, _, _ in companionApprovalCount += 1 } + companionApproval: { _, _, _, _ in companionApprovalCount += 1 } ) } @@ -399,7 +412,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { accountName: "Creator store", secretKeyHex: "secret", accountManager: manager, - companionApproval: { _, _, _ in + companionApproval: { _, _, _, _ in await companionApprovalGate.approve() try Task.checkCancellation() } diff --git a/BitkitTests/PubkyAuthRequestTests.swift b/BitkitTests/PubkyAuthRequestTests.swift index b1b4af240..cb4cb80a2 100644 --- a/BitkitTests/PubkyAuthRequestTests.swift +++ b/BitkitTests/PubkyAuthRequestTests.swift @@ -5,6 +5,7 @@ import XCTest final class PubkyAuthRequestTests: XCTestCase { private let relay = "https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F" private let secret = "e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s" + private let clientPublicKey = "5jsjx1o6fzu6aeeo697r3i5rx15zq41kikcye8wtwdqm4nb4tryo" func testProtocolUrlRecognizesPubkyAuthSchemeCaseInsensitively() { XCTAssertTrue(PubkyAuthRequest.isProtocolURL("pubkyauth://signin?caps=/pub/bitkit.to/:rw")) @@ -15,10 +16,11 @@ final class PubkyAuthRequestTests: XCTestCase { func testParseUrlPreservesRequestedCapabilities() throws { let capabilities = "/pub/bitkit.to/:rw" - let url = "pubkyauth://signin?caps=\(capabilities)&relay=https://httprelay.pubky.app/inbox/&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s" + let url = authUrl(capabilities: capabilities) let request = try PubkyAuthRequest.parse(url: url) + XCTAssertEqual(request.clientID, "paykit.test") XCTAssertEqual(request.capabilities, capabilities) XCTAssertEqual(request.permissions.count, 1) XCTAssertEqual(request.permissions[0].path, "/pub/bitkit.to/") @@ -63,13 +65,10 @@ final class PubkyAuthRequestTests: XCTestCase { XCTAssertEqual(request.bitkitClaim, .watchOnlyAccountV1) } - func testParseUrlRecognizesWatchOnlyAccountClaimWithCapabilityWhitespace() throws { + func testWatchOnlyCapabilityMatcherAllowsWhitespace() { let capabilities = PubkyAuthClaim.watchOnlyAccountCapabilities.replacingOccurrences(of: ",", with: " , ") - let url = authUrl(capabilities: capabilities, claimValues: [PubkyAuthClaim.watchOnlyAccountV1.rawValue]) - - let request = try PubkyAuthRequest.parse(url: url) - XCTAssertEqual(request.bitkitClaim, .watchOnlyAccountV1) + XCTAssertTrue(PubkyAuthClaim.matchesWatchOnlyAccountCapabilities(capabilities)) } func testParseUrlWithoutBitkitClaimPreservesNormalAuth() throws { @@ -262,6 +261,7 @@ final class PubkyAuthRequestTests: XCTestCase { let claims = claimValues .map { "&\(PubkyAuthClaim.queryParameter)=\($0)" } .joined() - return "pubkyauth://signin?caps=\(capabilities)&relay=\(relay)&secret=\(secret)\(claims)" + return "pubkyauth://signin_grant?caps=\(capabilities)&relay=\(relay)&secret=\(secret)" + + "&cid=paykit.test&cpk=\(clientPublicKey)\(claims)" } } diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index 43be620ac..82c5880a6 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -115,10 +115,44 @@ final class PubkyProfileManagerTests: XCTestCase { } @MainActor - func testCompleteAuthenticationClearsSessionWhenAuthIsCanceledAfterCompletion() async { + func testFailedSignOutMarksEnabledPaykitStateForReconciliation() { + var publicPending = false + var privatePending = false + + PubkyProfileManager.markPaykitReconciliationPendingAfterFailedSignOut( + publicSharingEnabled: true, + privateSharingEnabled: true, + setPublicReconciliationPending: { publicPending = $0 }, + setPrivateReconciliationPending: { privatePending = $0 } + ) + + XCTAssertTrue(publicPending) + XCTAssertTrue(privatePending) + } + + @MainActor + func testProfileDeletionQueuesRemovalRatherThanRepublish() throws { + try withIsolatedDefaults { defaults in + defaults.set(true, forKey: PublicPaykitService.publishingEnabledKey) + defaults.set(true, forKey: PrivatePaykitService.publishingEnabledKey) + var publicPending = false + + PubkyProfileManager.clearPaykitSharingAfterProfileDeletion( + defaults: defaults, + setPublicReconciliationPending: { publicPending = $0 } + ) + + XCTAssertTrue(publicPending) + XCTAssertEqual(PublicPaykitService.pendingReconciliationMode(defaults: defaults), .removePublishedState) + XCTAssertEqual(PrivatePaykitService.fullCleanupReconciliationMode(defaults: defaults), .removePublishedState) + } + } + + @MainActor + func testCompleteAuthenticationRevokesSessionWhenAuthIsCanceledAfterCompletion() async { let manager = PubkyProfileManager() let attemptID = UUID() - var didClearSession = false + var didDiscardSession = false manager.setActiveAuthAttemptIDForTesting(attemptID) manager.authState = .authenticating @@ -131,19 +165,62 @@ final class PubkyProfileManagerTests: XCTestCase { currentPublicKey: { "pubky_test" }, - clearSessionAccess: { - didClearSession = true + discardSessionAccess: { + didDiscardSession = true } ) XCTFail("Expected cancellation") } catch is CancellationError { - XCTAssertTrue(didClearSession) + XCTAssertTrue(didDiscardSession) XCTAssertNil(manager.activeAuthAttemptIDForTesting) } catch { XCTFail("Expected CancellationError, got \(error)") } } + @MainActor + func testCompleteAuthenticationRevokesSessionWhenActivationThrows() async { + let errors: [Error] = [PubkyServiceError.authFailed("offline"), CancellationError()] + + for thrownError in errors { + let manager = PubkyProfileManager() + manager.setActiveAuthAttemptIDForTesting(UUID()) + manager.authState = .authenticating + var didDiscardSession = false + + do { + try await manager.completeAuthenticationForTesting( + completeAuth: { throw thrownError }, + currentPublicKey: { "pubky_test" }, + discardSessionAccess: { didDiscardSession = true } + ) + XCTFail("Expected authentication activation to fail") + } catch { + XCTAssertTrue(didDiscardSession) + } + } + } + + @MainActor + func testDiscardAbandonedSessionForgetsLocalAccessWhenRevocationFails() async { + let manager = PubkyProfileManager() + var didRevokeSession = false + var didForgetSession = false + + await manager.discardAbandonedSessionForTesting( + revokeSessionAccess: { + didRevokeSession = true + throw PubkyServiceError.authFailed("offline") + }, + forgetSessionAccess: { + didForgetSession = true + } + ) + + XCTAssertTrue(didRevokeSession) + XCTAssertTrue(didForgetSession) + } + // MARK: - HomegateResponse Decoding private typealias HomegateResponse = PubkyProfileManager.HomegateResponse @@ -436,7 +513,7 @@ final class PubkyProfileManagerTests: XCTestCase { deleteKeychainValue: { key in store.removeValue(forKey: key.storageKey) }, - clearSessionAccess: { + forgetSessionAccess: { didClearSessionAccess = true }, signInWithSecretKey: { _ in @@ -473,7 +550,60 @@ final class PubkyProfileManagerTests: XCTestCase { deleteKeychainValue: { key in store.removeValue(forKey: key.storageKey) }, - clearSessionAccess: {}, + forgetSessionAccess: {}, + signInWithSecretKey: { _ in + XCTFail("Missing pubky state should not sign in") + return "unused-session" + }, + importExternalSession: { _ in + XCTFail("Missing pubky state should not import a session") + return "pubky_unused" + } + ) + + XCTAssertNil(store[KeychainEntryType.paykitSession.storageKey]) + XCTAssertNil(store[KeychainEntryType.pubkySecretKey.storageKey]) + } + + func testRestoreSessionBackupStateReplacesSessionWhenForgetFails() async throws { + var store = makeKeychainStore( + paykitSession: "stale-session", + pubkySecretKey: "stale-local-secret" + ) + + try await PubkyProfileManager.restoreSessionBackupState( + PubkySessionBackupV1(kind: .externalSession, sessionSecret: "backup-session"), + loadKeychainString: { store[$0.storageKey] }, + persistKeychainString: { store[$0.storageKey] = $1 }, + deleteKeychainValue: { store.removeValue(forKey: $0.storageKey) }, + forgetSessionAccess: { throw PubkyServiceError.authFailed("offline") }, + signInWithSecretKey: { _ in + XCTFail("External session restore should not sign in with a local secret") + return "unused-session" + }, + importExternalSession: { session in + store[KeychainEntryType.paykitSession.storageKey] = session + store.removeValue(forKey: KeychainEntryType.pubkySecretKey.storageKey) + return "pubky_external" + } + ) + + XCTAssertEqual(store[KeychainEntryType.paykitSession.storageKey], "backup-session") + XCTAssertNil(store[KeychainEntryType.pubkySecretKey.storageKey]) + } + + func testRestoreSessionBackupStateClearsCredentialsWhenForgetFailsWithoutBackup() async throws { + var store = makeKeychainStore( + paykitSession: "stale-session", + pubkySecretKey: "stale-local-secret" + ) + + try await PubkyProfileManager.restoreSessionBackupState( + nil, + loadKeychainString: { store[$0.storageKey] }, + persistKeychainString: { store[$0.storageKey] = $1 }, + deleteKeychainValue: { store.removeValue(forKey: $0.storageKey) }, + forgetSessionAccess: { throw PubkyServiceError.authFailed("offline") }, signInWithSecretKey: { _ in XCTFail("Missing pubky state should not sign in") return "unused-session" @@ -508,7 +638,7 @@ final class PubkyProfileManagerTests: XCTestCase { deleteKeychainValue: { key in store.removeValue(forKey: key.storageKey) }, - clearSessionAccess: {}, + forgetSessionAccess: {}, signInWithSecretKey: { secretKey in XCTAssertFalse(secretKey.isEmpty) store[KeychainEntryType.pubkySecretKey.storageKey] = secretKey @@ -542,7 +672,7 @@ final class PubkyProfileManagerTests: XCTestCase { deleteKeychainValue: { key in store.removeValue(forKey: key.storageKey) }, - clearSessionAccess: {}, + forgetSessionAccess: {}, signInWithSecretKey: { _ in throw PubkyServiceError.authFailed("offline") } @@ -653,6 +783,15 @@ final class PubkyProfileManagerTests: XCTestCase { ) } + private func withIsolatedDefaults(_ body: (UserDefaults) throws -> Void) throws { + let suiteName = "PubkyProfileManagerTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + + try body(defaults) + } + private func makeKeychainStore( mnemonic: String? = nil, paykitSession: String? = nil, diff --git a/BitkitTests/PublicPaykitServiceTests.swift b/BitkitTests/PublicPaykitServiceTests.swift index 94c525e53..e17abd75a 100644 --- a/BitkitTests/PublicPaykitServiceTests.swift +++ b/BitkitTests/PublicPaykitServiceTests.swift @@ -187,6 +187,23 @@ final class PublicPaykitServiceTests: XCTestCase { } } + func testPendingReconciliationHandlesWriterProducedSharingStates() throws { + let expectedModes: [(publicEnabled: Bool, privateEnabled: Bool, mode: PublicPaykitService.PendingReconciliationMode)] = [ + (true, true, .publishEndpoints), + (true, false, .publishEndpoints), + (false, false, .removePublishedState), + ] + + for expected in expectedModes { + try withIsolatedDefaults { defaults in + defaults.set(expected.publicEnabled, forKey: PublicPaykitService.publishingEnabledKey) + defaults.set(expected.privateEnabled, forKey: PrivatePaykitService.publishingEnabledKey) + + XCTAssertEqual(PublicPaykitService.pendingReconciliationMode(defaults: defaults), expected.mode) + } + } + } + private func endpoint(_ methodId: PublicPaykitService.MethodId, value: String) -> PublicPaykitService.Endpoint { PublicPaykitService.Endpoint( methodId: methodId, diff --git a/changelog.d/next/697.security.md b/changelog.d/next/697.security.md new file mode 100644 index 000000000..1088411f6 --- /dev/null +++ b/changelog.d/next/697.security.md @@ -0,0 +1 @@ +Added app-scoped Pubky authorization and secure sign-out, and fixed missing payment requests in history.