diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 86e18d2b6..f0d673f04 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -175,6 +175,29 @@ struct PaykitPaymentRequestPollingSchedule { } } +enum OrphanedKeychainCleanup { + static func perform( + hasNativeKeychain: Bool, + hasOrphanedRNKeychain: Bool, + deleteBitkitSharedIdentities: () throws -> Void, + wipePrivateKeychain: () throws -> Void, + cleanupRNKeychain: () -> Void + ) throws { + guard hasNativeKeychain || hasOrphanedRNKeychain else { + return + } + + // A reinstall must never orphan a shared Bitkit credential. Ring-owned + // accounts are outside this deletion's scope. + try deleteBitkitSharedIdentities() + try wipePrivateKeychain() + + if hasOrphanedRNKeychain { + cleanupRNKeychain() + } + } +} + struct AppScene: View { private static let initialPaykitSyncRetryDelays = Array(repeating: Duration.seconds(2), count: 14) @@ -386,7 +409,13 @@ struct AppScene: View { private var appEventContent: some View { configuredContent - .onChange(of: pubkyProfile.authState, initial: true) { _, authState in + .onChange(of: pubkyProfile.authState, initial: true) { previousAuthState, authState in + if let isAllowed = Self.paykitMaintenancePermission( + previousAuthState: previousAuthState, + authState: authState + ) { + wallet.setPaykitMaintenanceAllowed(isAllowed) + } if authState == .authenticated, let pk = pubkyProfile.publicKey { paykitPaymentRequestManager.activate(identity: pk) Task { @@ -757,7 +786,7 @@ struct AppScene: View { /// Handle orphaned keychain entries from previous app installs. /// If the installation marker doesn't exist but keychain has data, the app was reinstalled /// and the keychain data is orphaned (corresponding wallet data was deleted with the app). - private func handleOrphanedKeychain() { + private func handleOrphanedKeychain() throws { // If marker exists, app was installed before - keychain is valid if InstallationMarker.exists() { Logger.debug("Installation marker exists, skipping orphaned keychain check", context: "AppScene") @@ -772,26 +801,30 @@ struct AppScene: View { if hasNativeKeychain || hasOrphanedRNKeychain { Logger.warn("Orphaned keychain detected, wiping", context: "AppScene") - try? Keychain.wipeEntireKeychain() - - if hasOrphanedRNKeychain { - MigrationsService.shared.cleanupRNKeychain() - } + try OrphanedKeychainCleanup.perform( + hasNativeKeychain: hasNativeKeychain, + hasOrphanedRNKeychain: hasOrphanedRNKeychain, + deleteBitkitSharedIdentities: { + try SharedPubkyIdentityVault.deleteAllBitkitIdentities() + }, + wipePrivateKeychain: { + try Keychain.wipeEntireKeychain() + }, + cleanupRNKeychain: { + MigrationsService.shared.cleanupRNKeychain() + } + ) } // Create marker for this installation - do { - try InstallationMarker.create() - } catch { - Logger.error("Failed to create installation marker: \(error)", context: "AppScene") - } + try InstallationMarker.create() } @Sendable private func setupTask() async { do { // Handle orphaned keychain before anything else - handleOrphanedKeychain() + try handleOrphanedKeychain() await checkAndPerformRNMigration() try wallet.setWalletExistsState() @@ -941,6 +974,12 @@ struct AppScene: View { } if newPhase == .active { + wallet.setPaykitMaintenanceAllowed(false) + let sharedIdentityValidation = Task { + let canUsePaykit = await pubkyProfile.validateSharedIdentitySourceIfNeeded() + wallet.setPaykitMaintenanceAllowed(canUsePaykit) + return canUsePaykit + } // Reconnect a known hardware device so its connection indicator turns green again; if isPinVerified || !settings.pinEnabled { Task { await trezorManager.autoReconnect() } @@ -948,26 +987,55 @@ struct AppScene: View { if wallet.walletExists == true { Task { await clearDeliveredNotifications() - await LightningService.shared.reconnectPeers() - try? await wallet.sync() - await retryPendingPaykitEndpointRemoval() - await wallet.refreshPublicPaykitEndpointsOnForeground() - if PaykitFeatureFlags.isUIEnabled { - await refreshPrivateOnlyPaykitReceiverMarker() - let contactPublicKeys = contactsManager.contacts.map(\.publicKey) - await PrivatePaykitService.shared.startInitialLinkBurst( - for: contactPublicKeys, - savedPublicKeys: contactPublicKeys, - wallet: wallet, - reason: "foreground" - ) - await refreshIncomingPaykitPaymentRequests() - } + await Self.performForegroundMaintenance( + waitForSharedIdentityValidation: { await sharedIdentityValidation.value }, + walletMaintenance: { canUsePaykit in + await LightningService.shared.reconnectPeers() + try? await wallet.sync(allowPaykitMaintenance: canUsePaykit) + }, + paykitMaintenance: { + await retryPendingPaykitEndpointRemoval() + await wallet.refreshPublicPaykitEndpointsOnForeground() + if PaykitFeatureFlags.isUIEnabled { + await refreshPrivateOnlyPaykitReceiverMarker() + let contactPublicKeys = contactsManager.contacts.map(\.publicKey) + await PrivatePaykitService.shared.startInitialLinkBurst( + for: contactPublicKeys, + savedPublicKeys: contactPublicKeys, + wallet: wallet, + reason: "foreground" + ) + await refreshIncomingPaykitPaymentRequests() + } + } + ) } } } } + static func paykitMaintenancePermission( + previousAuthState: PubkyAuthState, + authState: PubkyAuthState + ) -> Bool? { + if previousAuthState != .authenticated, authState == .authenticated { + return true + } + return authState == .idle ? false : nil + } + + @MainActor + static func performForegroundMaintenance( + waitForSharedIdentityValidation: () async -> Bool, + walletMaintenance: (Bool) async -> Void, + paykitMaintenance: () async -> Void + ) async { + let canUsePaykit = await waitForSharedIdentityValidation() + await walletMaintenance(canUsePaykit) + guard canUsePaykit else { return } + await paykitMaintenance() + } + private func refreshPrivateOnlyPaykitReceiverMarker() async { let publicSharingEnabled = UserDefaults.standard.bool(forKey: PublicPaykitService.publishingEnabledKey) let privateSharingEnabled = UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey) diff --git a/Bitkit/Assets.xcassets/icons/key.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/key.imageset/Contents.json new file mode 100644 index 000000000..554d63e1a --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/key.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "key.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Bitkit/Assets.xcassets/icons/key.imageset/key.svg b/Bitkit/Assets.xcassets/icons/key.imageset/key.svg new file mode 100644 index 000000000..2b6335608 --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/key.imageset/key.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Bitkit/Bitkit.entitlements b/Bitkit/Bitkit.entitlements index 0898d896c..6c673bbaf 100644 --- a/Bitkit/Bitkit.entitlements +++ b/Bitkit/Bitkit.entitlements @@ -20,6 +20,7 @@ $(AppIdentifierPrefix)to.bitkit.signet $(AppIdentifierPrefix)to.bitkit.testnet $(AppIdentifierPrefix)to.bitkit.regtest + $(AppIdentifierPrefix)pubky.shared diff --git a/Bitkit/Components/Button/Button.swift b/Bitkit/Components/Button/Button.swift index 447a40528..050bdbcbf 100644 --- a/Bitkit/Components/Button/Button.swift +++ b/Bitkit/Components/Button/Button.swift @@ -66,6 +66,7 @@ struct CustomButton: View { let isDisabled: Bool let isLoading: Bool let shouldExpand: Bool + let labelKerning: CGFloat let background: AnyView? let action: (() async -> Void)? let destination: AnyView? @@ -85,6 +86,7 @@ struct CustomButton: View { isDisabled: Bool = false, isLoading: Bool = false, shouldExpand: Bool = false, + labelKerning: CGFloat = 0.4, background: (any View)? = nil ) { self.title = title @@ -94,6 +96,7 @@ struct CustomButton: View { self.isDisabled = isDisabled self.isLoading = isLoading self.shouldExpand = shouldExpand + self.labelKerning = labelKerning self.background = background.map { AnyView($0) } action = nil destination = nil @@ -108,6 +111,7 @@ struct CustomButton: View { isDisabled: Bool = false, isLoading: Bool = false, shouldExpand: Bool = false, + labelKerning: CGFloat = 0.4, background: (any View)? = nil, action: @escaping () async -> Void ) { @@ -118,6 +122,7 @@ struct CustomButton: View { self.isDisabled = isDisabled self.isLoading = isLoading self.shouldExpand = shouldExpand + self.labelKerning = labelKerning self.background = background.map { AnyView($0) } self.action = action destination = nil @@ -132,6 +137,7 @@ struct CustomButton: View { isDisabled: Bool = false, isLoading: Bool = false, shouldExpand: Bool = false, + labelKerning: CGFloat = 0.4, background: (any View)? = nil, destination: some View ) { @@ -142,6 +148,7 @@ struct CustomButton: View { self.isDisabled = isDisabled self.isLoading = isLoading self.shouldExpand = shouldExpand + self.labelKerning = labelKerning self.background = background.map { AnyView($0) } action = nil self.destination = AnyView(destination) @@ -158,6 +165,7 @@ struct CustomButton: View { isLoading: isLoading, isPressed: isPressed, shouldExpand: shouldExpand, + labelKerning: labelKerning, background: background )) case .secondary: @@ -168,13 +176,15 @@ struct CustomButton: View { isDisabled: effectiveIsDisabled, isPressed: isPressed, isLoading: isLoading, - shouldExpand: shouldExpand + shouldExpand: shouldExpand, + labelKerning: labelKerning )) case .tertiary: AnyView(TertiaryButtonView( title: title, icon: icon, - isPressed: isPressed + isPressed: isPressed, + labelKerning: labelKerning )) } } diff --git a/Bitkit/Components/Button/PrimaryButtonView.swift b/Bitkit/Components/Button/PrimaryButtonView.swift index 00dc27cdb..ce666d5a3 100644 --- a/Bitkit/Components/Button/PrimaryButtonView.swift +++ b/Bitkit/Components/Button/PrimaryButtonView.swift @@ -8,6 +8,7 @@ struct PrimaryButtonView: View { let isLoading: Bool let isPressed: Bool let shouldExpand: Bool + let labelKerning: CGFloat let background: AnyView? var body: some View { @@ -24,7 +25,7 @@ struct PrimaryButtonView: View { if size == .small { CaptionBText(title, textColor: .textPrimary) } else { - BodySSBText(title, textColor: .textPrimary) + BodySSBText(title, textColor: .textPrimary, kerning: labelKerning) } } } diff --git a/Bitkit/Components/Button/SecondaryButtonView.swift b/Bitkit/Components/Button/SecondaryButtonView.swift index 7f6c8f433..93f128d16 100644 --- a/Bitkit/Components/Button/SecondaryButtonView.swift +++ b/Bitkit/Components/Button/SecondaryButtonView.swift @@ -8,6 +8,7 @@ struct SecondaryButtonView: View { let isPressed: Bool var isLoading: Bool = false let shouldExpand: Bool + let labelKerning: CGFloat var body: some View { HStack(spacing: 8) { @@ -22,7 +23,7 @@ struct SecondaryButtonView: View { } else if size == .small { CaptionBText(title, textColor: textColor) } else { - BodySSBText(title, textColor: textColor) + BodySSBText(title, textColor: textColor, kerning: labelKerning) } } .frame(maxWidth: (size == .large || shouldExpand) ? .infinity : nil) diff --git a/Bitkit/Components/Button/TertiaryButtonView.swift b/Bitkit/Components/Button/TertiaryButtonView.swift index 50f76a3b7..97222e566 100644 --- a/Bitkit/Components/Button/TertiaryButtonView.swift +++ b/Bitkit/Components/Button/TertiaryButtonView.swift @@ -4,6 +4,7 @@ struct TertiaryButtonView: View { let title: String let icon: AnyView? let isPressed: Bool + let labelKerning: CGFloat var body: some View { HStack(spacing: 8) { @@ -11,7 +12,7 @@ struct TertiaryButtonView: View { icon } - BodySSBText(title, textColor: textColor) + BodySSBText(title, textColor: textColor, kerning: labelKerning) } .frame(maxWidth: .infinity) .frame(height: CustomButton.Size.large.height) diff --git a/Bitkit/Info.plist b/Bitkit/Info.plist index 5e1652411..561c66ec9 100644 --- a/Bitkit/Info.plist +++ b/Bitkit/Info.plist @@ -2,6 +2,8 @@ + SharedPubkyKeychainAccessGroup + $(AppIdentifierPrefix)pubky.shared CFBundleURLTypes diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 0415b928b..66fa1b37f 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -791,23 +791,7 @@ struct MainNavView: View { return } - let handlingResult = await pubkyProfile.handleAuthCallback(callback) - - switch handlingResult { - case let .trustedError(message): - app.toast( - type: .error, - title: t("profile__auth_error_title"), - description: message ?? t("other__qr_error_text") - ) - case .untrustedError: - app.toast( - type: .error, - title: t("profile__auth_error_title") - ) - case .handled, .ignored: - break - } + pubkyProfile.handleAuthCallback(callback) return } diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 02b1921d2..4d8996dff 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -8,21 +8,6 @@ enum PubkyAuthState: Equatable { case completingAuthentication case authenticated case error(String) - - func resetRingAuthViewStateIfNeeded( - isAuthenticating: Binding, - isWaitingForRing: Binding, - isLoadingAfterAuth: Binding - ) { - switch self { - case .idle, .authenticated, .error: - isAuthenticating.wrappedValue = false - isWaitingForRing.wrappedValue = false - isLoadingAfterAuth.wrappedValue = false - case .authenticating, .completingAuthentication: - break - } - } } enum PubkyRingAuthCallback: Equatable { @@ -30,15 +15,6 @@ enum PubkyRingAuthCallback: Equatable { case cancel(nonce: String?) case error(message: String?, nonce: String?) - var nonce: String? { - switch self { - case let .success(nonce), let .cancel(nonce): - return nonce - case let .error(_, nonce): - return nonce - } - } - static func parse(url: URL) -> PubkyRingAuthCallback? { guard url.scheme == "bitkit", url.host == "pubky-auth" else { return nil @@ -61,67 +37,6 @@ enum PubkyRingAuthCallback: Equatable { } } -enum PubkyRingAuthCallbackHandlingResult: Equatable { - case ignored - case handled - case trustedError(message: String?) - case untrustedError -} - -enum PubkyRingAuthURLBuilder { - static let successCallback = "bitkit://pubky-auth/success" - static let cancelCallback = "bitkit://pubky-auth/cancel" - static let errorCallback = "bitkit://pubky-auth/error" - static let source = "Bitkit" - - static func addingCallbacks(to authUrl: String, nonce: UUID? = nil) -> String? { - guard var components = URLComponents(string: authUrl), components.url != nil else { - return nil - } - - let callbackQuery = [ - ("x-success", callbackUrl(successCallback, nonce: nonce)), - ("x-cancel", callbackUrl(cancelCallback, nonce: nonce)), - ("x-error", callbackUrl(errorCallback, nonce: nonce)), - ("x-source", source), - ] - .map { "\($0.0)=\(Self.percentEncodedQueryValue($0.1))" } - .joined(separator: "&") - - components.percentEncodedQuery = [components.percentEncodedQuery, callbackQuery] - .compactMap { $0 } - .filter { !$0.isEmpty } - .joined(separator: "&") - - return components.url?.absoluteString - } - - static func ringHandoffURL(from authUrl: String) -> URL? { - guard var components = URLComponents(string: authUrl), components.scheme?.lowercased() == "pubkyauth" else { - return nil - } - - components.scheme = "pubkyring" - components.host = "signin" - components.path = "" - return components.url - } - - private static func callbackUrl(_ baseUrl: String, nonce: UUID?) -> String { - guard let nonce else { - return baseUrl - } - - return "\(baseUrl)?nonce=\(percentEncodedQueryValue(nonce.uuidString))" - } - - private static func percentEncodedQueryValue(_ value: String) -> String { - var allowedCharacters = CharacterSet.urlQueryAllowed - allowedCharacters.remove(charactersIn: ":#[]@!$&'()*+,;=/?") - return value.addingPercentEncoding(withAllowedCharacters: allowedCharacters) ?? value - } -} - private enum PubkyProfileManagerError: LocalizedError { case avatarEncodingFailed @@ -138,6 +53,37 @@ enum PubkySignupError: Error { case inProgress } +private actor PubkyIdentityLifecycleLock { + private var isLocked = false + private var waiters: [CheckedContinuation] = [] + + func withLock(_ operation: () async throws -> T) async rethrows -> T { + await lock() + defer { unlock() } + return try await operation() + } + + private func lock() async { + guard isLocked else { + isLocked = true + return + } + + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + private func unlock() { + guard !waiters.isEmpty else { + isLocked = false + return + } + + waiters.removeFirst().resume() + } +} + @MainActor class PubkyProfileManager: ObservableObject { enum SessionInitializationResult: Equatable { @@ -146,6 +92,13 @@ class PubkyProfileManager: ObservableObject { case restorationFailed } + enum SharedRingIdentityDiscoveryState: Equatable { + case initial + case loading + case loaded + case unavailable + } + @Published var authState: PubkyAuthState = .idle @Published var profile: PubkyProfile? @Published var publicKey: String? @@ -156,10 +109,18 @@ class PubkyProfileManager: ObservableObject { @Published private(set) var cachedName: String? @Published private(set) var cachedImageUri: String? @Published private(set) var isProfileSetupPending: Bool + @Published private(set) var sharedRingIdentities: [SharedPubkyIdentityOption] = [] + @Published private(set) var sharedRingIdentityDiscoveryState: SharedRingIdentityDiscoveryState = .initial - private var activeAuthAttemptID: UUID? + private nonisolated static let identityLifecycleLock = PubkyIdentityLifecycleLock() private var isSignupInFlight = false + nonisolated static func withIdentityLifecycleLock( + _ operation: () async throws -> T + ) async rethrows -> T { + try await identityLifecycleLock.withLock(operation) + } + init() { cachedName = UserDefaults.standard.string(forKey: Self.cachedNameKey) cachedImageUri = UserDefaults.standard.string(forKey: Self.cachedImageUriKey) @@ -170,22 +131,42 @@ class PubkyProfileManager: ObservableObject { /// Initializes Paykit and restores any persisted session. func initialize() async { + await Self.withIdentityLifecycleLock { + await self.initializeLocked() + } + } + + private func initializeLocked() async { isInitialized = false initializationErrorMessage = nil sessionRestorationFailed = false let result: SessionInitializationResult - do { - result = try await Task.detached { - try await Self.initializePersistedSession() - }.value - } catch { - Logger.error("Failed to initialize paykit: \(error)", context: "PubkyProfileManager") - authState = .idle - initializationErrorMessage = error.localizedDescription - return + if sharedIdentitySourceIsUnavailable() { + do { + try await Self.clearUnavailableSharedIdentitySession() + } catch { + Logger.error("Failed to clear unavailable shared Pubky session: \(error)", context: "PubkyProfileManager") + } + result = .restorationFailed + } else { + do { + result = try await Task.detached { + try await Self.initializePersistedSession() + }.value + } catch { + Logger.error("Failed to initialize paykit: \(error)", context: "PubkyProfileManager") + authState = .idle + initializationErrorMessage = error.localizedDescription + return + } } + await applySessionInitializationResult(result) + isInitialized = true + } + + private func applySessionInitializationResult(_ result: SessionInitializationResult) async { switch result { case .noSession: clearAuthenticatedState() @@ -194,13 +175,12 @@ class PubkyProfileManager: ObservableObject { publicKey = pk authState = .authenticated Logger.info("Paykit session restored for \(pk)", context: "PubkyProfileManager") + await reconcileBitkitOwnedIdentityIfNeededLocked(publicKey: pk) Task { await loadProfile() } case .restorationFailed: clearAuthenticatedState() sessionRestorationFailed = true } - - isInitialized = true } // MARK: - Key Derivation & Identity Creation @@ -275,6 +255,33 @@ class PubkyProfileManager: ObservableObject { try await Task.detached { try Keychain.loadString(key: .pubkySecretKey) }.value } ) async throws { + try await Self.withIdentityLifecycleLock { + try await self.createIdentityLocked( + name: name, + bio: bio, + links: links, + tags: tags, + existingImageUrl: existingImageUrl, + avatarImage: avatarImage, + loadStoredSecretKey: loadStoredSecretKey + ) + } + } + + private func createIdentityLocked( + name: String, + bio: String, + links: [PubkyProfileLink], + tags: [String], + existingImageUrl: String?, + avatarImage: UIImage?, + loadStoredSecretKey: () async throws -> String? + ) async throws { + try Task.checkCancellation() + guard try SharedPubkyIdentityReferenceStore.load() == nil else { + throw PubkyServiceError.authFailed("A Pubky identity is already recoverable") + } + if isProfileSetupPending, let publicKey { try await createProfile( publicKey: publicKey, @@ -288,6 +295,16 @@ class PubkyProfileManager: ObservableObject { return } + // Resuming an interrupted setup already returned above, so reaching here creates or + // restores an identity. A stored session that no local secret can re-sign-in belongs to + // an external or borrowed identity: signing up would overwrite its still-recoverable + // session secret, so refuse instead of silently replacing it. + let hasStoredLocalSecret = try Keychain.loadString(key: .pubkySecretKey)?.isEmpty == false + let hasStoredSession = try Keychain.loadString(key: .paykitSession)?.isEmpty == false + guard hasStoredLocalSecret || !hasStoredSession else { + throw PubkyServiceError.authFailed("A Pubky identity is already recoverable") + } + setProfileSetupPending(false) try await Self.completeIdentityCreation( loadStoredSecretKey: loadStoredSecretKey, @@ -390,9 +407,17 @@ class PubkyProfileManager: ObservableObject { profile = createdProfile cacheProfileMetadata(createdProfile) setProfileSetupPending(false) + try SharedPubkyIdentityReferenceStore.delete() + await reconcileBitkitOwnedIdentityIfNeededLocked(publicKey: publicKey) } func approveSignupAuth(request: PubkyAuthRequest) async throws { + try await Self.withIdentityLifecycleLock { + try await self.approveSignupAuthLocked(request: request) + } + } + + private func approveSignupAuthLocked(request: PubkyAuthRequest) async throws { guard request.isSignup, let homeserver = request.homeserverPublicKey else { throw PubkyServiceError.invalidAuthUrl } @@ -519,7 +544,56 @@ class PubkyProfileManager: ObservableObject { cacheProfileMetadata(updatedProfile) } + /// Orders profile deletion so it fails closed before anything is erased. Contact cleanup + /// deletes remote records over the session a borrowed identity established, so a revoked + /// source has to abort the flow here rather than at the deletion that follows the cleanup. + /// Validation takes the lifecycle lock on its own; no network work runs under that lock. + nonisolated static func deleteProfileWithContactCleanup( + revalidateSource: () async throws -> Void, + deleteContacts: () async -> Void, + deleteProfile: () async throws -> Void + ) async throws { + try await revalidateSource() + await deleteContacts() + try await deleteProfile() + } + + /// Fails closed when a source revoked a borrowed identity, before a destructive flow starts. + /// Owned identities never read the shared vault. + func ensureSharedIdentitySourceIsValid() async throws { + try await Self.withIdentityLifecycleLock { + try self.revalidateSharedIdentitySource() + } + } + + static func revalidateSharedIdentitySourceBeforeWrite() throws { + try validateSharedIdentitySource( + reference: SharedPubkyIdentityReferenceStore.load(), + isSourceAvailable: isRingAvailable(), + loadSharedCredential: { try SharedPubkyIdentityVault.loadCredential(reference: $0) } + ) + } + func deleteProfile() async throws { + try await Self.withIdentityLifecycleLock { + try await self.deleteProfileLocked() + } + } + + private func deleteProfileLocked() async throws { + // A source can revoke a borrowed identity between session establishment and the next + // foreground validation, so revalidate it here, under the lifecycle lock, before any + // remote deletion or cleanup runs on a credential Ring may no longer authorize. + try revalidateSharedIdentitySource() + + let deletedPublicKey = publicKey + let ownsIdentity = hasLocalSecretKeyForCurrentProfile + + // Remove and verify the interoperability mirror before touching the canonical private identity. + if ownsIdentity, let deletedPublicKey { + try SharedPubkyIdentityVault.deleteBitkitIdentity(pubky: deletedPublicKey) + } + await Self.removePrivatePaykitEndpointsBestEffort(context: "PubkyProfileManager.deleteProfile") do { try await Task.detached { @@ -534,7 +608,10 @@ class PubkyProfileManager: ObservableObject { } Self.clearPaykitSharingAfterProfileDeletion() - try await signOut(cleanPrivatePaykitEndpoints: false) + try await signOutLocked(cleanPrivatePaykitEndpoints: false) + if ownsIdentity, let deletedPublicKey { + try SharedPubkyIdentityVault.deleteBitkitIdentity(pubky: deletedPublicKey) + } } private func writeProfile( @@ -558,6 +635,9 @@ class PubkyProfileManager: ObservableObject { } static func isRingAvailable() -> Bool { + // This is an availability hint, not an identity proof: URL schemes can be claimed by + // another app. Shared-Keychain entitlement and payload validation remain the trust + // boundary. A source-authenticated liveness handshake is a follow-up release hardening. guard let url = URL(string: "pubkyring://check") else { return false } @@ -565,203 +645,368 @@ class PubkyProfileManager: ObservableObject { return UIApplication.shared.canOpenURL(url) } - // MARK: - Auth Flow (Ring) - - func cancelAuthentication() async { - activeAuthAttemptID = nil + // MARK: - Shared Identity Discovery - do { - try await Task.detached { - try await PubkyService.cancelAuth() - }.value - restoreAuthStateAfterAuthFlow() - } catch { - restoreAuthStateAfterAuthFlow() - Logger.warn("Cancel auth failed: \(error)", context: "PubkyProfileManager") - } + func refreshSharedRingIdentities() async { + await refreshSharedRingIdentities( + isRingAvailable: Self.isRingAvailable(), + loadReferences: { + try await Task.detached { + try SharedPubkyIdentityVault.list(source: .ring) + }.value + } + ) } - func handleAuthCallback(_ callback: PubkyRingAuthCallback) async -> PubkyRingAuthCallbackHandlingResult { - guard isCurrentAuthCallback(callback) else { - return await handleInvalidAuthCallback(callback) + func refreshSharedRingIdentities( + isRingAvailable: Bool, + loadReferences: () async throws -> [SharedPubkyIdentityRefV1] + ) async { + guard publicKey == nil else { + sharedRingIdentities = [] + sharedRingIdentityDiscoveryState = .initial + return } - switch callback { - case .success: - Logger.info("Pubky Ring returned auth success callback", context: "PubkyProfileManager") - case .cancel: - Logger.info("Pubky Ring returned auth cancel callback", context: "PubkyProfileManager") - await cancelAuthentication() - case let .error(message, _): - Logger.warn("Pubky Ring returned auth error callback: \(message ?? "Unknown error")", context: "PubkyProfileManager") - await cancelAuthentication() - setAuthFlowError(message ?? t("profile__auth_error_title")) - return .trustedError(message: message) + guard isRingAvailable else { + sharedRingIdentities = [] + sharedRingIdentityDiscoveryState = .loaded + return } - return .handled - } + sharedRingIdentityDiscoveryState = .loading - private func handleInvalidAuthCallback(_ callback: PubkyRingAuthCallback) async -> PubkyRingAuthCallbackHandlingResult { - switch callback { - case .success: - Logger.warn("Ignoring Pubky Ring auth success callback with missing or invalid nonce", context: "PubkyProfileManager") - case .cancel: - Logger.warn("Ignoring Pubky Ring auth cancel callback with missing or invalid nonce", context: "PubkyProfileManager") - case let .error(message, _): - Logger.warn( - "Ignoring Pubky Ring auth error callback with missing or invalid nonce: \(message ?? "Unknown error")", - context: "PubkyProfileManager" - ) - } + do { + let references = try await loadReferences() + var options: [SharedPubkyIdentityOption] = [] + for reference in references { + guard let prefixedPubky = SharedPubkyKeyFormat.prefixed(reference.pubky) else { + continue + } + let profile = await fetchRemoteProfile(publicKey: prefixedPubky) + ?? PubkyProfile.placeholder(publicKey: prefixedPubky) + options.append(SharedPubkyIdentityOption(reference: reference, profile: profile)) + } - return .ignored + sharedRingIdentities = options.sorted { + let lhsName = $0.profile.name.localizedLowercase + let rhsName = $1.profile.name.localizedLowercase + return lhsName == rhsName + ? $0.reference.pubky < $1.reference.pubky + : lhsName < rhsName + } + sharedRingIdentityDiscoveryState = .loaded + } catch SharedPubkyIdentityError.missingEntitlement { + sharedRingIdentities = [] + sharedRingIdentityDiscoveryState = .unavailable + Logger.info("Shared Pubky Keychain entitlement is not available yet", context: "PubkyProfileManager") + } catch { + sharedRingIdentities = [] + sharedRingIdentityDiscoveryState = .unavailable + Logger.warn("Failed to discover Pubky Ring identities: \(error)", context: "PubkyProfileManager") + } } - func startAuthentication() async throws { - let attemptID = UUID() - activeAuthAttemptID = attemptID - authState = .authenticating + @discardableResult + func useSharedRingIdentity(_ option: SharedPubkyIdentityOption) async throws -> String { + try await Self.withIdentityLifecycleLock { + try await self.useSharedRingIdentityLocked(option) + } + } + private func useSharedRingIdentityLocked(_ option: SharedPubkyIdentityOption) async throws -> String { + guard publicKey == nil, + try SharedPubkyIdentityReferenceStore.load() == nil, + try Keychain.loadString(key: .paykitSession)?.isEmpty != false, + try Keychain.loadString(key: .pubkySecretKey)?.isEmpty != false + else { + throw PubkyServiceError.authFailed("A Pubky identity is already recoverable") + } guard Self.isRingAvailable() else { - activeAuthAttemptID = nil - restoreAuthStateAfterAuthFlow() - throw PubkyServiceError.ringNotInstalled + throw SharedPubkyIdentityError.sourceUnavailable } - let authUrl: String + authState = .authenticating do { - authUrl = try await Task.detached { - try await PubkyService.startAuth() + try Task.checkCancellation() + let secretKey = try await Task.detached { + try SharedPubkyIdentityVault.loadCredential(reference: option.reference) }.value + try Task.checkCancellation() + let prefixedPubky = try await Self.establishSharedIdentitySession( + reference: option.reference, + secretKey: secretKey, + saveReference: { try SharedPubkyIdentityReferenceStore.save($0) }, + signIn: { try await PubkyService.signInSharedIdentity(secretKeyHex: $0) }, + currentPublicKey: { await PubkyService.currentPublicKey() }, + clearSession: { try await PubkyService.clearExternalSessionAccess() }, + deleteReference: { try SharedPubkyIdentityReferenceStore.delete() } + ) + + UserDefaults.standard.set(false, forKey: PrivatePaykitService.publishingEnabledKey) + Self.notifyAppStateBackupChanged() + publicKey = prefixedPubky + profile = option.profile + cacheProfileMetadata(option.profile) + authState = .completingAuthentication + await loadProfile() + return prefixedPubky } catch { - activeAuthAttemptID = nil - restoreAuthStateAfterAuthFlow() + authState = .idle throw error } + } - guard activeAuthAttemptID == attemptID else { - throw CancellationError() + nonisolated static func establishSharedIdentitySession( + reference: SharedPubkyIdentityRefV1, + secretKey: String, + saveReference: (SharedPubkyIdentityRefV1) throws -> Void, + signIn: (String) async throws -> String, + currentPublicKey: () async -> String?, + clearSession: () async throws -> Void, + deleteReference: () throws -> Void + ) async throws -> String { + do { + // The reference is the crash-safety marker. A launch after this write can revalidate + // the source and sign in again; no borrowed session can exist without it. + try saveReference(reference) + _ = try await signIn(secretKey) + + guard let signedInPublicKey = await currentPublicKey(), + SharedPubkyKeyFormat.normalizedBare(signedInPublicKey) == reference.pubky, + let prefixedPubky = SharedPubkyKeyFormat.prefixed(reference.pubky) + else { + throw SharedPubkyIdentityError.secretDoesNotMatchPublicKey + } + return prefixedPubky + } catch let adoptionError { + do { + // The durable source reference is also the cleanup-pending marker. Keep it until + // the local session is verifiably gone so launch/foreground validation retries. + try await clearSession() + try deleteReference() + } catch { + throw error + } + throw adoptionError } + } - let callbackAuthUrl = PubkyRingAuthURLBuilder.addingCallbacks(to: authUrl, nonce: attemptID) ?? authUrl + /// Revalidates a borrowed identity without retaining its shared secret. + func validateSharedIdentitySourceIfNeeded() async -> Bool { + await Self.withIdentityLifecycleLock { + await self.validateSharedIdentitySourceIfNeededLocked() + } + } - guard let url = PubkyRingAuthURLBuilder.ringHandoffURL(from: callbackAuthUrl) else { - await cancelPendingAuthSetup() - activeAuthAttemptID = nil - restoreAuthStateAfterAuthFlow() - throw PubkyServiceError.invalidAuthUrl + private func validateSharedIdentitySourceIfNeededLocked() async -> Bool { + let reference: SharedPubkyIdentityRefV1? + do { + reference = try SharedPubkyIdentityReferenceStore.load() + } catch { + if Self.shouldDisconnectSharedIdentity(after: error) { + await disconnectUnavailableSharedIdentityLocked() + } else { + Logger.warn("Deferring shared Pubky reference validation: \(error)", context: "PubkyProfileManager") + } + return false } - let canOpen = UIApplication.shared.canOpenURL(url) - guard canOpen else { - await cancelPendingAuthSetup() - activeAuthAttemptID = nil - restoreAuthStateAfterAuthFlow() - throw PubkyServiceError.ringNotInstalled + guard let reference else { + if let publicKey { + await reconcileBitkitOwnedIdentityIfNeededLocked(publicKey: publicKey) + } + return true } - let didOpen = await UIApplication.shared.open(url) - guard didOpen else { - await cancelPendingAuthSetup() - activeAuthAttemptID = nil - restoreAuthStateAfterAuthFlow() - throw PubkyServiceError.authFailed("Failed to open Pubky Ring") + guard reference.sourceApp == .ring, Self.isRingAvailable() else { + await disconnectUnavailableSharedIdentityLocked() + return false } - } - /// Long-polls the relay, activates the SDK session, then loads the profile. - @discardableResult - func completeAuthentication() async throws -> String { - try await completeAuthentication( - completeAuth: { try await PubkyService.completeAuth() }, - currentPublicKey: { await PubkyService.currentPublicKey() }, - discardSessionAccess: { sessionSecret in - await Task.detached { - await PaykitSdkService.shared.discardCompletedAuthSession(sessionSecret: sessionSecret) - }.value + do { + _ = try await Task.detached { + try SharedPubkyIdentityVault.loadCredential(reference: reference) + }.value + + let restorationResult = try await Self.retrySharedSessionRestorationIfNeeded( + currentPublicKey: publicKey, + restore: { + try await Task.detached { + try await Self.initializePersistedSession() + }.value + } + ) + if let restorationResult { + initializationErrorMessage = nil + sessionRestorationFailed = false + await applySessionInitializationResult(restorationResult) + isInitialized = true } - ) + return Self.canPerformPaykitMaintenance(afterSharedSessionRestoration: restorationResult) + } catch { + if Self.shouldDisconnectSharedIdentity(after: error) { + Logger.warn("Shared Pubky source is no longer valid: \(error)", context: "PubkyProfileManager") + await disconnectUnavailableSharedIdentityLocked() + } else { + Logger.warn("Deferring shared Pubky source validation: \(error)", context: "PubkyProfileManager") + } + return false + } } - @discardableResult - private func completeAuthentication( - completeAuth: @escaping () async throws -> String, - currentPublicKey: @escaping () async -> String?, - discardSessionAccess: @escaping (String) async -> Void - ) async throws -> String { - guard let attemptID = activeAuthAttemptID else { - throw CancellationError() + nonisolated static func retrySharedSessionRestorationIfNeeded( + currentPublicKey: String?, + restore: () async throws -> SessionInitializationResult + ) async throws -> SessionInitializationResult? { + guard currentPublicKey == nil else { + return nil } - var completedSessionSecret: String? + return try await restore() + } - do { - completedSessionSecret = try await completeAuth() - try Task.checkCancellation() - guard activeAuthAttemptID == attemptID else { - throw CancellationError() - } + static func canPerformPaykitMaintenance(afterSharedSessionRestoration result: SessionInitializationResult?) -> Bool { + switch result { + case nil, .some(.restored): + return true + case .some(.noSession), .some(.restorationFailed): + return false + } + } - guard let pk = await currentPublicKey() else { - throw PubkyServiceError.sessionNotActive - } - try Task.checkCancellation() - guard activeAuthAttemptID == attemptID else { - throw CancellationError() - } + nonisolated static func shouldDisconnectSharedIdentity(after error: Error) -> Bool { + guard let error = error as? SharedPubkyIdentityError else { + return false + } - UserDefaults.standard.set(false, forKey: PrivatePaykitService.publishingEnabledKey) - Self.notifyAppStateBackupChanged() + switch error { + case .invalidRecord, .invalidPublicKey, .secretDoesNotMatchPublicKey, + .sourceUnavailable, .sourceIdentityMissing, .provenanceConflict: + return true + case .unavailable, .temporarilyUnavailable, .missingEntitlement: + return false + } + } - activeAuthAttemptID = nil - publicKey = pk - authState = .completingAuthentication - Logger.info("Pubky auth completed for \(pk)", context: "PubkyProfileManager") - await loadProfile() - return pk - } catch is CancellationError { - await discardCompletedAuthSessionIfNeeded( - completedSessionSecret, - discardSessionAccess: discardSessionAccess - ) - if activeAuthAttemptID == attemptID { - activeAuthAttemptID = nil - restoreAuthStateAfterAuthFlow() + private func sharedIdentitySourceIsUnavailable() -> Bool { + do { + guard let reference = try SharedPubkyIdentityReferenceStore.load() else { + return false } - throw CancellationError() - } catch let serviceError as PubkyServiceError { - await discardCompletedAuthSessionIfNeeded( - completedSessionSecret, - discardSessionAccess: discardSessionAccess - ) - guard activeAuthAttemptID == attemptID else { - throw CancellationError() + return reference.sourceApp != .ring || !Self.isRingAvailable() + } catch { + let isDefinitivelyUnavailable = Self.shouldDisconnectSharedIdentity(after: error) + if !isDefinitivelyUnavailable { + Logger.warn("Deferring shared Pubky source availability check: \(error)", context: "PubkyProfileManager") } + return isDefinitivelyUnavailable + } + } - activeAuthAttemptID = nil - restoreAuthStateAfterAuthFlow() - throw serviceError + private func disconnectUnavailableSharedIdentityLocked() async { + sharedRingIdentities = [] + // Stop UI/event-driven identity work before remote cleanup so nothing can republish while + // the cached session is used one final time to remove Bitkit's endpoints. + clearAuthenticatedState() + sessionRestorationFailed = true + do { + try await Self.clearUnavailableSharedIdentitySession() } catch { - await discardCompletedAuthSessionIfNeeded( - completedSessionSecret, - discardSessionAccess: discardSessionAccess + // Keep the durable reference as a cleanup-pending marker and retry on the next + // launch/foreground validation. The borrowed identity remains unavailable in the UI. + Logger.error("Failed to clear unavailable shared Pubky session: \(error)", context: "PubkyProfileManager") + } + } + + static func clearUnavailableSharedIdentitySession( + removePrivatePaykitEndpoints: () async -> Bool = { + await PubkyProfileManager.removePrivatePaykitEndpointsBestEffort( + context: "PubkyProfileManager.sharedIdentitySourceLoss" ) - guard activeAuthAttemptID == attemptID else { - throw CancellationError() - } + }, + removePublicPaykitEndpoints: () async -> Bool = { + await PubkyProfileManager.removePublicPaykitEndpointsBestEffort( + context: "PubkyProfileManager.sharedIdentitySourceLoss" + ) + }, + clearSession: () async throws -> Void = { + try await PubkyService.clearExternalSessionAccess() + }, + clearPrivatePaykitState: () async -> Void = { + await PrivatePaykitService.shared.closeAndClear() + }, + clearPaykitSharingState: () async -> Void = { + await PubkyProfileManager.clearPublicPaykitSharingState() + }, + deleteReference: () throws -> Void = { + try SharedPubkyIdentityReferenceStore.delete() + } + ) async throws { + // Published endpoints outlive the borrowed identity, so remove them while its session is + // still usable. Cleanup is best effort: source loss must still revoke local session access. + _ = await removePrivatePaykitEndpoints() + _ = await removePublicPaykitEndpoints() + + // Keep the durable reference until both identity-specific stores are gone. A session + // failure leaves local state and its retry markers intact; a reference failure leaves an + // empty cache and a retry marker. + try await clearSession() + await clearPrivatePaykitState() + await clearPaykitSharingState() + try deleteReference() + } + + static func clearSharedIdentitySession( + clearSession: () async throws -> Void = { + try await PubkyService.clearExternalSessionAccess() + }, + deleteReference: () throws -> Void = { + try SharedPubkyIdentityReferenceStore.delete() + } + ) async throws { + // Session-first ordering prevents an orphaned session from ever outliving its source + // reference. If either step fails, the remaining reference drives a later retry. + try await clearSession() + try deleteReference() + } + + private func reconcileBitkitOwnedIdentityIfNeededLocked(publicKey: String) async { + // Mirroring intentionally ignores the Paykit UI flag: existing profiles must become + // discoverable to Pubky Ring after an upgrade even when Bitkit's Paykit UI is hidden. + guard (try? SharedPubkyIdentityReferenceStore.load()) == nil, + let secretKey = try? Keychain.loadString(key: .pubkySecretKey), + !secretKey.isEmpty, + Self.hasLocalSecretKey(for: publicKey) + else { + return + } - activeAuthAttemptID = nil - setAuthFlowError(error.localizedDescription) - throw error + do { + try await Task.detached { + try SharedPubkyIdentityVault.publishBitkitIdentity(pubky: publicKey, secretKey: secretKey) + }.value + } catch SharedPubkyIdentityError.missingEntitlement { + Logger.info("Deferring shared Pubky mirror until its entitlement is available", context: "PubkyProfileManager") + } catch { + Logger.warn("Failed to reconcile Bitkit-owned shared Pubky identity: \(error)", context: "PubkyProfileManager") } } - private func discardCompletedAuthSessionIfNeeded( - _ completedSessionSecret: String?, - discardSessionAccess: @escaping (String) async -> Void - ) async { - guard let completedSessionSecret else { return } - await discardSessionAccess(completedSessionSecret) + // MARK: - Legacy Ring Callbacks + + /// Old callbacks remain recognized so they cannot fall through to payment handling. + func handleAuthCallback(_ callback: PubkyRingAuthCallback) { + switch callback { + case .success: + Logger.warn("Ignoring Pubky Ring auth success callback with missing or invalid nonce", context: "PubkyProfileManager") + case .cancel: + Logger.warn("Ignoring Pubky Ring auth cancel callback with missing or invalid nonce", context: "PubkyProfileManager") + case let .error(message, _): + Logger.warn( + "Ignoring Pubky Ring auth error callback with missing or invalid nonce: \(message ?? "Unknown error")", + context: "PubkyProfileManager" + ) + } } private func discardAbandonedSession() async { @@ -800,22 +1045,6 @@ class PubkyProfileManager: ObservableObject { authState = .authenticated } - private func restoreAuthStateAfterAuthFlow() { - authState = publicKey == nil ? .idle : .authenticated - } - - private func setAuthFlowError(_ message: String) { - authState = publicKey == nil ? .error(message) : .authenticated - } - - private func isCurrentAuthCallback(_ callback: PubkyRingAuthCallback) -> Bool { - guard let activeAuthAttemptID else { - return false - } - - return callback.nonce == activeAuthAttemptID.uuidString - } - #if DEBUG func completeSignupAuthenticationForTesting( publicKey: String, @@ -833,27 +1062,6 @@ class PubkyProfileManager: ObservableObject { ) } - func setActiveAuthAttemptIDForTesting(_ attemptID: UUID?) { - activeAuthAttemptID = attemptID - } - - var activeAuthAttemptIDForTesting: UUID? { - activeAuthAttemptID - } - - @discardableResult - func completeAuthenticationForTesting( - completeAuth: @escaping () async throws -> String, - currentPublicKey: @escaping () async -> String?, - discardSessionAccess: @escaping (String) async -> Void - ) async throws -> String { - try await completeAuthentication( - completeAuth: completeAuth, - currentPublicKey: currentPublicKey, - discardSessionAccess: discardSessionAccess - ) - } - func discardAbandonedSessionForTesting( revokeSessionAccess: @escaping () async throws -> Void, forgetSessionAccess: @escaping () async throws -> Void @@ -907,11 +1115,14 @@ class PubkyProfileManager: ObservableObject { // MARK: - Sign Out static func clearLocalState() async { + // Callers replacing or deleting a Bitkit-owned private identity must first + // delete and verify its shared mirror. Ring-owned records are never deleted here. do { try await PubkyService.forgetSessionAccess() } catch { Logger.warn("Failed to forget local Pubky session access: \(error)", context: "PubkyProfileManager") } + try? SharedPubkyIdentityReferenceStore.delete() await clearLocalAppState() } @@ -927,6 +1138,16 @@ class PubkyProfileManager: ObservableObject { notifyAppStateBackupChanged() } + private nonisolated static func deletePrivateIdentityCredentials() throws { + try Keychain.delete(key: .paykitSession) + try Keychain.delete(key: .pubkySecretKey) + guard try Keychain.load(key: .paykitSession) == nil, + try Keychain.load(key: .pubkySecretKey) == nil + else { + throw KeychainError.failedToDelete + } + } + private static func clearPublicPaykitSharingState() { UserDefaults.standard.set(false, forKey: PublicPaykitService.publishingEnabledKey) UserDefaults.standard.set(false, forKey: PrivatePaykitService.publishingEnabledKey) @@ -961,12 +1182,15 @@ class PubkyProfileManager: ObservableObject { } } - static func removePublicPaykitEndpointsBestEffort(context: String) async { + @discardableResult + static func removePublicPaykitEndpointsBestEffort(context: String) async -> Bool { do { try await removePublicPaykitEndpoints(context: context) PublicPaykitService.setCleanupPending(false) + return true } catch { PublicPaykitService.setCleanupPending(true) + return false } } @@ -981,20 +1205,40 @@ class PubkyProfileManager: ObservableObject { } } - static func removePrivatePaykitEndpointsBestEffort(context: String) async { + @discardableResult + static func removePrivatePaykitEndpointsBestEffort(context: String) async -> Bool { do { try await removePrivatePaykitEndpoints(context: context) PrivatePaykitService.setContactSharingCleanupPending(false) + return true } catch { PrivatePaykitService.setContactSharingCleanupPending(true) + return false } } func signOut() async throws { - try await signOut(cleanPrivatePaykitEndpoints: true) + try await Self.withIdentityLifecycleLock { + try await self.signOutLocked(cleanPrivatePaykitEndpoints: true) + } } - private func signOut(cleanPrivatePaykitEndpoints: Bool) async throws { + private func signOutLocked(cleanPrivatePaykitEndpoints: Bool) async throws { + let sharedReference = try SharedPubkyIdentityReferenceStore.load() + let localSecret = try Keychain.loadString(key: .pubkySecretKey) + if sharedReference != nil, localSecret?.isEmpty == false { + throw SharedPubkyIdentityError.provenanceConflict + } + + let ownsIdentity = hasLocalSecretKeyForCurrentProfile + if localSecret?.isEmpty == false, !ownsIdentity { + throw SharedPubkyIdentityError.provenanceConflict + } + let hasSharedReference = sharedReference != nil + if ownsIdentity, let sourcePublicKey = publicKey { + try SharedPubkyIdentityVault.deleteBitkitIdentity(pubky: sourcePublicKey) + } + let publicSharingEnabled = UserDefaults.standard.bool(forKey: PublicPaykitService.publishingEnabledKey) let privateSharingEnabled = UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey) @@ -1005,6 +1249,12 @@ class PubkyProfileManager: ObservableObject { } await Self.removePublicPaykitEndpointsBestEffort(context: "PubkyProfileManager.signOut") try await PubkyService.signOut() + + if hasSharedReference { + try await Self.clearSharedIdentitySession() + } else if ownsIdentity { + try Self.deletePrivateIdentityCredentials() + } await Self.clearLocalAppState() }.value } catch { @@ -1015,6 +1265,10 @@ class PubkyProfileManager: ObservableObject { throw error } + if ownsIdentity, let sourcePublicKey = publicKey { + try SharedPubkyIdentityVault.deleteBitkitIdentity(pubky: sourcePublicKey) + } + setProfileSetupPending(false) clearAuthenticatedState() } @@ -1047,7 +1301,39 @@ class PubkyProfileManager: ObservableObject { } func refreshSessionIfPossible(after error: Error) async -> Bool { - await Self.refreshSessionIfPossible( + await Self.withIdentityLifecycleLock { + await self.refreshSessionIfPossibleLocked(after: error) + } + } + + private func refreshSessionIfPossibleLocked(after error: Error) async -> Bool { + if let reference = try? SharedPubkyIdentityReferenceStore.load() { + guard Self.isSessionRefreshableError(error), + Self.isRingAvailable() + else { + return false + } + + do { + let secretKey = try SharedPubkyIdentityVault.loadCredential(reference: reference) + _ = try await PubkyService.signInSharedIdentity(secretKeyHex: secretKey) + guard let signedInPublicKey = await PubkyService.currentPublicKey(), + SharedPubkyKeyFormat.normalizedBare(signedInPublicKey) == reference.pubky + else { + throw SharedPubkyIdentityError.secretDoesNotMatchPublicKey + } + Logger.info("Refreshed Pubky session from source-owned identity", context: "PubkyProfileManager") + return true + } catch { + Logger.warn("Failed to refresh source-owned Pubky session: \(error)", context: "PubkyProfileManager") + if Self.shouldDisconnectSharedIdentity(after: error) { + await disconnectUnavailableSharedIdentityLocked() + } + return false + } + } + + return await Self.refreshSessionIfPossible( after: error, loadKeychainString: { try Keychain.loadString(key: $0) }, signInWithSecretKey: { try await PubkyService.signIn(secretKeyHex: $0) } @@ -1091,10 +1377,14 @@ class PubkyProfileManager: ObservableObject { publicKey = nil profile = nil authState = .idle + sharedRingIdentities = [] + sharedRingIdentityDiscoveryState = .initial clearCachedProfileMetadata() } private func activeSessionSecret() throws -> String { + try revalidateSharedIdentitySource() + guard let sessionSecret = try? Keychain.loadString(key: .paykitSession), !sessionSecret.isEmpty else { @@ -1103,6 +1393,31 @@ class PubkyProfileManager: ObservableObject { return sessionSecret } + /// Re-reads a borrowed credential just in time. Owned identities never touch the shared vault. + private func revalidateSharedIdentitySource() throws { + try Self.validateSharedIdentitySource( + reference: SharedPubkyIdentityReferenceStore.load(), + isSourceAvailable: Self.isRingAvailable(), + loadSharedCredential: { try SharedPubkyIdentityVault.loadCredential(reference: $0) } + ) + } + + nonisolated static func validateSharedIdentitySource( + reference: SharedPubkyIdentityRefV1?, + isSourceAvailable: Bool, + loadSharedCredential: (SharedPubkyIdentityRefV1) throws -> String + ) throws { + guard let reference else { + return + } + guard reference.sourceApp == .ring, isSourceAvailable else { + throw SharedPubkyIdentityError.sourceUnavailable + } + // Loading re-derives the public key from the source record and fails closed when the + // source has removed, rotated or invalidated the identity Bitkit borrowed. + _ = try loadSharedCredential(reference) + } + // MARK: - Session & Backup Helpers var isAuthenticated: Bool { @@ -1114,7 +1429,9 @@ class PubkyProfileManager: ObservableObject { } nonisolated static func hasStoredIdentity() throws -> Bool { - for key in [KeychainEntryType.paykitSession, .pubkySecretKey] { + // A source-owned reference is a recoverable identity too: signup must never create a + // local identity while a borrowed one is connected or still pending cleanup. + for key in [KeychainEntryType.paykitSession, .pubkySecretKey, .sharedPubkyIdentityReference] { if let value = try Keychain.loadString(key: key), !value.isEmpty { return true } @@ -1122,6 +1439,55 @@ class PubkyProfileManager: ObservableObject { return false } + /// Returns the active identity key only at the point of use. Shared keys are never persisted privately. + func activeIdentitySecretKey() throws -> String { + guard let expectedPublicKey = publicKey else { + throw PubkyServiceError.sessionNotActive + } + + let reference = try SharedPubkyIdentityReferenceStore.load() + let localSecret = try Keychain.loadString(key: .pubkySecretKey) + return try Self.resolveActiveIdentitySecretKey( + expectedPublicKey: expectedPublicKey, + reference: reference, + localSecret: localSecret, + isSourceAvailable: Self.isRingAvailable(), + loadSharedCredential: { + try SharedPubkyIdentityVault.loadCredential(reference: $0) + } + ) + } + + nonisolated static func resolveActiveIdentitySecretKey( + expectedPublicKey: String, + reference: SharedPubkyIdentityRefV1?, + localSecret: String?, + isSourceAvailable: Bool, + loadSharedCredential: (SharedPubkyIdentityRefV1) throws -> String + ) throws -> String { + if let reference { + guard localSecret?.isEmpty != false else { + throw SharedPubkyIdentityError.provenanceConflict + } + guard reference.sourceApp == .ring, isSourceAvailable else { + throw SharedPubkyIdentityError.sourceUnavailable + } + guard SharedPubkyKeyFormat.normalizedBare(expectedPublicKey) == reference.pubky else { + throw SharedPubkyIdentityError.provenanceConflict + } + return try loadSharedCredential(reference) + } + + guard let localSecret, !localSecret.isEmpty, + let derivedPublicKey = try? publicKeyFromSecretKey(localSecret), + SharedPubkyKeyFormat.normalizedBare(derivedPublicKey) == + SharedPubkyKeyFormat.normalizedBare(expectedPublicKey) + else { + throw SharedPubkyIdentityError.provenanceConflict + } + return localSecret + } + nonisolated static func hasLocalSecretKey(for publicKey: String?) -> Bool { guard let publicKey, let secretKeyHex = try? Keychain.loadString(key: .pubkySecretKey), @@ -1140,6 +1506,13 @@ class PubkyProfileManager: ObservableObject { try Keychain.loadString(key: $0) } ) throws -> PubkySessionBackupV1? { + if let sharedReference = try loadKeychainString(.sharedPubkyIdentityReference), + !sharedReference.isEmpty + { + // The source app remains authoritative; a borrowed identity is not a portable backup. + return nil + } + if let secretKeyHex = try loadKeychainString(.pubkySecretKey), !secretKeyHex.isEmpty { @@ -1169,6 +1542,9 @@ class PubkyProfileManager: ObservableObject { deleteKeychainValue: (KeychainEntryType) throws -> Void = { try Keychain.delete(key: $0) }, + deleteBitkitSharedIdentities: () throws -> Void = { + try SharedPubkyIdentityVault.deleteAllBitkitIdentities() + }, forgetSessionAccess: @escaping () async throws -> Void = { try await PubkyService.forgetSessionAccess() }, @@ -1179,21 +1555,64 @@ class PubkyProfileManager: ObservableObject { try await PubkyService.importExternalSession(secret: $0) } ) async throws { + try await withIdentityLifecycleLock { + try await restoreSessionBackupStateLocked( + backup, + loadKeychainString: loadKeychainString, + persistKeychainString: persistKeychainString, + deleteKeychainValue: deleteKeychainValue, + deleteBitkitSharedIdentities: deleteBitkitSharedIdentities, + forgetSessionAccess: forgetSessionAccess, + signInWithSecretKey: signInWithSecretKey, + importExternalSession: importExternalSession + ) + } + } + + private nonisolated static func restoreSessionBackupStateLocked( + _ backup: PubkySessionBackupV1?, + loadKeychainString: (KeychainEntryType) throws -> String?, + persistKeychainString: (KeychainEntryType, String) throws -> Void, + deleteKeychainValue: (KeychainEntryType) throws -> Void, + deleteBitkitSharedIdentities: () throws -> Void, + forgetSessionAccess: @escaping () async throws -> Void, + signInWithSecretKey: @escaping (String) async throws -> String, + importExternalSession: @escaping (String) async throws -> String + ) async throws { + let localSecretKey = try loadKeychainString(.pubkySecretKey) + let sharedReference = try loadKeychainString(.sharedPubkyIdentityReference) + if localSecretKey?.isEmpty == false, sharedReference?.isEmpty == false { + throw SharedPubkyIdentityError.provenanceConflict + } + + if localSecretKey?.isEmpty == false { + // Backup restore can replace an identity without going through AppReset. + // Verify every Bitkit-owned mirror is gone before clearing private state. + try deleteBitkitSharedIdentities() + } + do { try await forgetSessionAccess() } catch { Logger.warn("Failed to forget existing Pubky session before restore: \(error)", context: "PubkyProfileManager") } + try deleteKeychainValue(.paykitSession) + try deleteKeychainValue(.pubkySecretKey) + try deleteKeychainValue(.sharedPubkyIdentityReference) + guard try loadKeychainString(.paykitSession) == nil, + try loadKeychainString(.pubkySecretKey) == nil, + try loadKeychainString(.sharedPubkyIdentityReference) == nil + else { + throw KeychainError.failedToDelete + } switch backup?.kind { case .none: // Backups without pubky state do not carry recoverable pubky credentials. - try? deleteKeychainValue(.paykitSession) - try? deleteKeychainValue(.pubkySecretKey) + break case .localSeed: let secretKeyHex = try deriveLocalSecretKeyFromWalletSeed(loadKeychainString: loadKeychainString) try persistKeychainString(.pubkySecretKey, secretKeyHex) - try? deleteKeychainValue(.paykitSession) _ = try await signInWithSecretKey(secretKeyHex) case .externalSession: guard let sessionSecret = backup?.sessionSecret, @@ -1205,20 +1624,30 @@ class PubkyProfileManager: ObservableObject { } } - private func cancelPendingAuthSetup() async { - do { - try await Task.detached { - try await PubkyService.cancelAuth() - }.value - } catch { - Logger.warn("Cancel pending auth setup failed: \(error)", context: "PubkyProfileManager") - } - } - private nonisolated static func initializePersistedSession() async throws -> SessionInitializationResult { try await PubkyService.initialize() let savedSecret = try Keychain.loadString(key: .paykitSession) + if let sharedReference = try SharedPubkyIdentityReferenceStore.load() { + do { + let sharedSecret = try SharedPubkyIdentityVault.loadCredential(reference: sharedReference) + return await resolveSharedSessionInitialization( + reference: sharedReference, + savedSessionSecret: savedSecret, + sharedSecretKey: sharedSecret, + importSession: { try await PubkyService.importExternalSession(secret: $0) }, + signInWithSharedSecret: { try await PubkyService.signInSharedIdentity(secretKeyHex: $0) }, + currentPublicKey: { await PubkyService.currentPublicKey() } + ) + } catch { + Logger.warn("Shared Pubky session source is unavailable: \(error)", context: "PubkyProfileManager") + if shouldDisconnectSharedIdentity(after: error) { + try? await clearUnavailableSharedIdentitySession() + } + return .restorationFailed + } + } + let secretKeyHex = try Keychain.loadString(key: .pubkySecretKey) return await resolveSessionInitialization( savedSessionSecret: savedSecret, @@ -1231,6 +1660,43 @@ class PubkyProfileManager: ObservableObject { ) } + nonisolated static func resolveSharedSessionInitialization( + reference: SharedPubkyIdentityRefV1, + savedSessionSecret: String?, + sharedSecretKey: String, + importSession: (String) async throws -> String, + signInWithSharedSecret: (String) async throws -> String, + currentPublicKey: () async -> String? + ) async -> SessionInitializationResult { + if let savedSessionSecret, !savedSessionSecret.isEmpty { + do { + let restoredPublicKey = try await importSession(savedSessionSecret) + guard SharedPubkyKeyFormat.normalizedBare(restoredPublicKey) == reference.pubky, + let prefixedPubky = SharedPubkyKeyFormat.prefixed(reference.pubky) + else { + throw SharedPubkyIdentityError.secretDoesNotMatchPublicKey + } + return .restored(publicKey: prefixedPubky) + } catch { + Logger.warn("Shared Pubky session expired; signing in again from its source", context: "PubkyProfileManager") + } + } + + do { + _ = try await signInWithSharedSecret(sharedSecretKey) + guard let signedInPublicKey = await currentPublicKey(), + SharedPubkyKeyFormat.normalizedBare(signedInPublicKey) == reference.pubky, + let prefixedPubky = SharedPubkyKeyFormat.prefixed(reference.pubky) + else { + throw SharedPubkyIdentityError.secretDoesNotMatchPublicKey + } + return .restored(publicKey: prefixedPubky) + } catch { + Logger.warn("Could not restore source-owned Pubky session: \(error)", context: "PubkyProfileManager") + return .restorationFailed + } + } + private nonisolated static func notifyAppStateBackupChanged() { Task { @MainActor in SettingsViewModel.shared.notifyAppStateChanged() diff --git a/Bitkit/Models/PubkyPublicKeyFormat.swift b/Bitkit/Models/PubkyPublicKeyFormat.swift index 7cd74508c..b7a53e525 100644 --- a/Bitkit/Models/PubkyPublicKeyFormat.swift +++ b/Bitkit/Models/PubkyPublicKeyFormat.swift @@ -13,7 +13,16 @@ enum PubkyPublicKeyFormat { static func normalized(_ input: String) -> String? { let boundedInput = bounded(input) - let rawKey = boundedInput.hasPrefix(prefix) ? String(boundedInput.dropFirst(prefix.count)) : boundedInput + let rawKey: String + if boundedInput.count == rawKeyLength { + rawKey = boundedInput + } else if boundedInput.count == maximumInputLength, + boundedInput.hasPrefix(prefix) + { + rawKey = String(boundedInput.dropFirst(prefix.count)) + } else { + return nil + } guard rawKey.count == rawKeyLength else { return nil @@ -47,7 +56,12 @@ enum PubkyPublicKeyFormat { static func displayTruncated(_ input: String) -> String { let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) - let rawKey = trimmed.lowercased().hasPrefix(prefix) ? String(trimmed.dropFirst(prefix.count)) : trimmed + // A bare key is never a prefixed one, so its leading characters stay even when they + // spell "pubky". Anything else keeps the historic behaviour of stripping the prefix. + let isBareKey = trimmed.count == rawKeyLength + let rawKey = !isBareKey && trimmed.lowercased().hasPrefix(prefix) + ? String(trimmed.dropFirst(prefix.count)) + : trimmed guard rawKey.count > 10 else { return rawKey } return "\(rawKey.prefix(4))...\(rawKey.suffix(4))" diff --git a/Bitkit/Models/SharedPubkyIdentity.swift b/Bitkit/Models/SharedPubkyIdentity.swift new file mode 100644 index 000000000..2201eaf8e --- /dev/null +++ b/Bitkit/Models/SharedPubkyIdentity.swift @@ -0,0 +1,126 @@ +import Foundation + +enum SharedPubkyIdentitySource: String, Codable, Equatable { + case ring = "app.pubkyring" + case bitkit = "to.bitkit" +} + +enum SharedPubkyKeyFormat { + private static let prefix = "pubky" + private static let bareKeyLength = 52 + private static let allowedCharacters = Set("ybndrfg8ejkmcpqxot1uwisza345h769") + private static let secretKeyLength = 64 + private static let secretKeyCharacters = Set("0123456789abcdef") + + static func normalizedBare(_ value: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let bare: String + if trimmed.count == bareKeyLength { + bare = trimmed + } else if trimmed.count == prefix.count + bareKeyLength, + trimmed.hasPrefix(prefix) + { + bare = String(trimmed.dropFirst(prefix.count)) + } else { + return nil + } + guard bare.count == bareKeyLength, + bare.allSatisfy({ allowedCharacters.contains($0) }) + else { + return nil + } + return bare + } + + static func isCanonicalSecretKey(_ value: String) -> Bool { + value.count == secretKeyLength && + value.allSatisfy { secretKeyCharacters.contains($0) } + } + + static func prefixed(_ bareValue: String) -> String? { + guard let bare = normalizedBare(bareValue), bare == bareValue else { + return nil + } + return "\(prefix)\(bare)" + } +} + +/// App-private pointer to an identity whose canonical secret remains owned by another app. +struct SharedPubkyIdentityRefV1: Codable, Equatable { + static let currentVersion = 1 + + let version: Int + let sourceApp: SharedPubkyIdentitySource + let pubky: String + + init(sourceApp: SharedPubkyIdentitySource, pubky: String) throws { + guard let normalizedPubky = SharedPubkyKeyFormat.normalizedBare(pubky) else { + throw SharedPubkyIdentityError.invalidPublicKey + } + + version = Self.currentVersion + self.sourceApp = sourceApp + self.pubky = normalizedPubky + } +} + +/// Payload stored in the shared Keychain access group by the app that owns the identity. +struct SharedPubkyIdentityRecordV1: Codable, Equatable { + static let currentVersion = 1 + + let version: Int + let sourceApp: SharedPubkyIdentitySource + let pubky: String + let secretKey: String + + init(sourceApp: SharedPubkyIdentitySource, pubky: String, secretKey: String) { + version = Self.currentVersion + self.sourceApp = sourceApp + self.pubky = pubky + self.secretKey = secretKey + } +} + +struct SharedPubkyIdentityOption: Identifiable { + let reference: SharedPubkyIdentityRefV1 + let profile: PubkyProfile + + var id: String { + reference.pubky + } +} + +enum SharedPubkyIdentityError: LocalizedError, Equatable { + case unavailable + case temporarilyUnavailable + case missingEntitlement + case invalidRecord + case invalidPublicKey + case secretDoesNotMatchPublicKey + case sourceUnavailable + case sourceIdentityMissing + case provenanceConflict + + var errorDescription: String? { + switch self { + case .unavailable: + return "Shared Pubky identities are unavailable" + case .temporarilyUnavailable: + return "Shared Pubky identities are temporarily unavailable" + case .missingEntitlement: + return "Shared Pubky Keychain access is not configured" + case .invalidRecord: + return "The shared Pubky identity is invalid" + case .invalidPublicKey: + return "The shared Pubky public key is invalid" + case .secretDoesNotMatchPublicKey: + return "The shared Pubky secret does not match its public key" + case .sourceUnavailable: + return "Pubky Ring is unavailable" + case .sourceIdentityMissing: + return "The selected Pubky Ring identity is no longer available" + case .provenanceConflict: + return "Conflicting Pubky identity sources require recovery" + } + } +} diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 9c968fc6d..a24dffb65 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1111,7 +1111,7 @@ "contacts__add_error" = "Failed to add contact"; "contacts__add_disclaimer" = "Please note that you and {name} must add each other as contacts to pay each other privately. Otherwise, the payment will be visible publicly."; "contacts__import_nav_title" = "Import"; -"contacts__import_found_title" = "Found\nprofile & contacts"; +"contacts__import_found_title" = "Found\ncontacts"; "contacts__import_found_description" = "Bitkit found profile and contacts data connected to pubky {key}"; "contacts__import_select" = "Select"; "contacts__import_all" = "Import All"; @@ -1190,8 +1190,11 @@ "profile__sign_out_description" = "This will disconnect your Pubky profile from Bitkit. You can reconnect at any time."; "profile__ring_waiting" = "Waiting for authorization from Pubky Ring…"; "profile__ring_loading" = "Loading your profile…"; -"profile__choice_title" = "Join the\npubky web"; -"profile__choice_description" = "Create a new pubky and profile in Bitkit, or import an existing profile with Pubky Ring."; +"profile__choice_title" = "ENTER THE\nFREEDOM WEB"; +"profile__choice_description" = "Create a new pubky and profile in Bitkit."; +"profile__choice_description_existing" = "Choose an existing pubky from Pubky Ring."; +"profile__ring_discovery_error" = "Could not load profiles from Pubky Ring."; +"profile__choice_new_pubky" = "NEW PUBKY"; "profile__choice_create" = "Create profile with Bitkit"; "profile__choice_import" = "Import with Pubky Ring"; "profile__deriving_keys" = "Deriving your keys…"; diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 81c98bf21..a3738a0d9 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -192,6 +192,7 @@ actor PaykitPaymentProofService { private let store: any PaykitPaymentProofStoring private let lightningPaymentLookup: any PaykitLightningPaymentProofLookingUp private let onchainPaymentLookup: any PaykitOnchainPaymentProofLookingUp + private let revalidateSourceBeforeWrite: @Sendable () async throws -> Void private let logInfo: @Sendable (String) -> Void private let logWarning: @Sendable (String) -> Void @@ -200,6 +201,9 @@ actor PaykitPaymentProofService { store: any PaykitPaymentProofStoring = PaykitPaymentProofStore(), lightningPaymentLookup: any PaykitLightningPaymentProofLookingUp = PaykitLightningPaymentProofLookup(), onchainPaymentLookup: any PaykitOnchainPaymentProofLookingUp = PaykitOnchainPaymentProofLookup(), + revalidateSourceBeforeWrite: @escaping @Sendable () async throws -> Void = { + try PubkyProfileManager.revalidateSharedIdentitySourceBeforeWrite() + }, logInfo: @escaping @Sendable (String) -> Void = { Logger.info($0, context: "PaykitPaymentProof") }, @@ -211,6 +215,7 @@ actor PaykitPaymentProofService { self.store = store self.lightningPaymentLookup = lightningPaymentLookup self.onchainPaymentLookup = onchainPaymentLookup + self.revalidateSourceBeforeWrite = revalidateSourceBeforeWrite self.logInfo = logInfo self.logWarning = logWarning } @@ -467,6 +472,8 @@ actor PaykitPaymentProofService { do { let pendingProofs = try await loadProofs() guard !pendingProofs.isEmpty else { return } + try await revalidateSourceBeforeWrite() + try Task.checkCancellation() guard let identityStatus = try await sdk.identityStatus(), identityStatus.liveSessionAvailable, let publicKey = identityStatus.publicKey, @@ -581,6 +588,8 @@ actor PaykitPaymentProofService { private func submit(_ pendingProof: PendingPaykitPaymentProof) async -> Bool { guard let proofData = pendingProof.proofData else { return false } do { + try await revalidateSourceBeforeWrite() + try Task.checkCancellation() guard let identityStatus = try await sdk.identityStatus(), identityStatus.liveSessionAvailable, PubkyPublicKeyFormat.matches(identityStatus.publicKey, pendingProof.identity) diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index b1630d55c..2b4cc2855 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -505,6 +505,7 @@ struct PaykitPaymentRequestService { private let sdk: any PaykitPaymentRequestSdkHandling private let now: @Sendable () -> Date private let isPrivatePaymentPublishingEnabled: @Sendable () -> Bool + private let revalidateSourceBeforeWrite: @Sendable () async throws -> Void private let logWarning: @Sendable (String) -> Void private let incomingRejectionLog = IncomingPaykitPaymentRequestRejectionLog() @@ -514,6 +515,9 @@ struct PaykitPaymentRequestService { isPrivatePaymentPublishingEnabled: @escaping @Sendable () -> Bool = { UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey) }, + revalidateSourceBeforeWrite: @escaping @Sendable () async throws -> Void = { + try PubkyProfileManager.revalidateSharedIdentitySourceBeforeWrite() + }, logWarning: @escaping @Sendable (String) -> Void = { Logger.warn($0, context: "PaykitPaymentRequest") } @@ -521,6 +525,7 @@ struct PaykitPaymentRequestService { self.sdk = sdk self.now = now self.isPrivatePaymentPublishingEnabled = isPrivatePaymentPublishingEnabled + self.revalidateSourceBeforeWrite = revalidateSourceBeforeWrite self.logWarning = logWarning } @@ -636,12 +641,14 @@ struct PaykitPaymentRequestService { acceptedPaymentEndpointIdentifiers: acceptedPaymentEndpointIdentifiers, metadata: Paykit.PrivateJsonObject(text: metadataText) ) - let record = try await sdk.proposePaymentRequest( - counterparty: target.publicKey, - counterpartyReceiverPath: target.receiverPath, - terms: terms, - expectedIdentity: expectedIdentity - ) + let record = try await performLifecycleWrite { + try await sdk.proposePaymentRequest( + counterparty: target.publicKey, + counterpartyReceiverPath: target.receiverPath, + terms: terms, + expectedIdentity: expectedIdentity + ) + } let reports = await (try? processPendingMessages()) ?? [] let deliveryStatus = proposalWasSent(record, reports: reports) ? PaykitPaymentRequest.DeliveryStatus.sent : .queued return PaykitPaymentRequest( @@ -685,11 +692,13 @@ struct PaykitPaymentRequestService { proposalDate: validationDate )) let iconURI: String? = if let iconData = draft.iconData { - try await sdk.uploadProfileAvatar( - bytes: Self.compressedSubscriptionIcon(iconData), - contentType: "image/jpeg", - expectedIdentity: expectedIdentity - ) + try await performLifecycleWrite { + try await sdk.uploadProfileAvatar( + bytes: Self.compressedSubscriptionIcon(iconData), + contentType: "image/jpeg", + expectedIdentity: expectedIdentity + ) + } } else { nil } @@ -711,12 +720,14 @@ struct PaykitPaymentRequestService { proposalDate: proposalDate ) try PaykitSubscriptionProposal.validate(terms) - let record = try await sdk.proposePaymentRequest( - counterparty: target.publicKey, - counterpartyReceiverPath: target.receiverPath, - terms: terms, - expectedIdentity: expectedIdentity - ) + let record = try await performLifecycleWrite { + try await sdk.proposePaymentRequest( + counterparty: target.publicKey, + counterpartyReceiverPath: target.receiverPath, + terms: terms, + expectedIdentity: expectedIdentity + ) + } let reports = await (try? processPendingMessages()) ?? [] let deliveryStatus = proposalWasSent(record, reports: reports) ? PaykitPaymentRequest.DeliveryStatus.sent @@ -772,11 +783,13 @@ struct PaykitPaymentRequestService { throw PaykitPaymentRequestError.requestExpired } - _ = try await sdk.acceptPaymentRequest( - counterparty: request.counterparty, - counterpartyReceiverPath: request.counterpartyReceiverPath, - paymentRequestId: request.paymentRequestId - ) + _ = try await performLifecycleWrite { + try await sdk.acceptPaymentRequest( + counterparty: request.counterparty, + counterpartyReceiverPath: request.counterpartyReceiverPath, + paymentRequestId: request.paymentRequestId + ) + } _ = try? await processPendingMessages() } @@ -785,22 +798,26 @@ struct PaykitPaymentRequestService { throw PaykitPaymentRequestError.requestExpired } - _ = try await sdk.rejectPaymentRequest( - counterparty: request.counterparty, - counterpartyReceiverPath: request.counterpartyReceiverPath, - paymentRequestId: request.paymentRequestId, - reason: nil - ) + _ = try await performLifecycleWrite { + try await sdk.rejectPaymentRequest( + counterparty: request.counterparty, + counterpartyReceiverPath: request.counterpartyReceiverPath, + paymentRequestId: request.paymentRequestId, + reason: nil + ) + } _ = try? await processPendingMessages() } func cancel(_ request: PaykitPaymentRequest) async throws { - _ = try await sdk.cancelPaymentRequest( - counterparty: request.counterparty, - counterpartyReceiverPath: request.counterpartyReceiverPath, - paymentRequestId: request.paymentRequestId, - reason: nil - ) + _ = try await performLifecycleWrite { + try await sdk.cancelPaymentRequest( + counterparty: request.counterparty, + counterpartyReceiverPath: request.counterpartyReceiverPath, + paymentRequestId: request.paymentRequestId, + reason: nil + ) + } _ = try? await processPendingMessages() } @@ -809,11 +826,13 @@ struct PaykitPaymentRequestService { throw PaykitPaymentRequestError.requestExpired } - let record = try await sdk.acceptPaymentRequest( - counterparty: subscription.counterparty, - counterpartyReceiverPath: subscription.counterpartyReceiverPath, - paymentRequestId: subscription.paymentRequestId - ) + let record = try await performLifecycleWrite { + try await sdk.acceptPaymentRequest( + counterparty: subscription.counterparty, + counterpartyReceiverPath: subscription.counterpartyReceiverPath, + paymentRequestId: subscription.paymentRequestId + ) + } _ = try? await processPendingMessages() guard let subscription = PaykitSubscription(record: record) else { throw PaykitPaymentRequestError.requestUnavailable @@ -826,12 +845,14 @@ struct PaykitPaymentRequestService { throw PaykitPaymentRequestError.requestUnavailable } - let record = try await sdk.cancelPaymentRequest( - counterparty: subscription.counterparty, - counterpartyReceiverPath: subscription.counterpartyReceiverPath, - paymentRequestId: subscription.paymentRequestId, - reason: nil - ) + let record = try await performLifecycleWrite { + try await sdk.cancelPaymentRequest( + counterparty: subscription.counterparty, + counterpartyReceiverPath: subscription.counterpartyReceiverPath, + paymentRequestId: subscription.paymentRequestId, + reason: nil + ) + } _ = try? await processPendingMessages() guard let subscription = PaykitSubscription(record: record) else { throw PaykitPaymentRequestError.requestUnavailable @@ -839,6 +860,12 @@ struct PaykitPaymentRequestService { return subscription } + private func performLifecycleWrite(_ write: () async throws -> T) async throws -> T { + try await revalidateSourceBeforeWrite() + try Task.checkCancellation() + return try await write() + } + private static func acceptedPaymentEndpointIdentifiers() -> [String] { PublicPaykitService.MethodId.publishableMethodIds.compactMap { methodId in if methodId == .bitcoinLightningBolt11 { diff --git a/Bitkit/Services/PrivatePaykitService+Backup.swift b/Bitkit/Services/PrivatePaykitService+Backup.swift index 7020a5fb2..2aed90250 100644 --- a/Bitkit/Services/PrivatePaykitService+Backup.swift +++ b/Bitkit/Services/PrivatePaykitService+Backup.swift @@ -4,7 +4,10 @@ import Foundation extension PrivatePaykitService { func backupSnapshot() async throws -> String? { - guard await PubkyService.currentPublicKey() != nil else { + guard try await Self.shouldExportBackupState( + currentPublicKey: PubkyService.currentPublicKey(), + loadSharedIdentityReference: { try SharedPubkyIdentityReferenceStore.load() } + ) else { return nil } let backup = try await Backup( @@ -22,6 +25,14 @@ extension PrivatePaykitService { return encoded } + nonisolated static func shouldExportBackupState( + currentPublicKey: String?, + loadSharedIdentityReference: () throws -> SharedPubkyIdentityRefV1? + ) throws -> Bool { + guard currentPublicKey != nil else { return false } + return try loadSharedIdentityReference() == nil + } + func restoreBackup(_ backup: String?) async throws { initialLinkBurstTask?.cancel() initialLinkBurstTask = nil diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index a08c8410c..1f9e265f6 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -6,7 +6,6 @@ import Paykit enum PubkyServiceError: LocalizedError { case invalidAuthUrl - case ringNotInstalled case sessionNotActive case authFailed(String) case profileNotFound @@ -15,8 +14,6 @@ enum PubkyServiceError: LocalizedError { switch self { case .invalidAuthUrl: return "Failed to generate auth URL" - case .ringNotInstalled: - return "Pubky Ring is not installed" case .sessionNotActive: return "No active Pubky session" case let .authFailed(reason): @@ -68,23 +65,6 @@ enum PubkyService { try? await PaykitSdkService.shared.currentPublicKey() } - // MARK: - Auth Flow - - /// Step 1: Generate the pubkyauth:// URL to open in Pubky Ring. - static func startAuth() async throws -> String { - try await PaykitSdkService.shared.startAuth() - } - - /// Step 2: Long-poll until Ring approves. Returns the raw session secret. - static func completeAuth() async throws -> String { - try await PaykitSdkService.shared.completeAuth() - } - - /// Cancel an in-progress auth relay poll started by `startAuth`. - static func cancelAuth() async throws { - await PaykitSdkService.shared.cancelAuth() - } - // MARK: - Auth Approval (Bitkit as authenticator) /// Parse a pubkyauth:// URL to extract details for UI display. @@ -269,6 +249,15 @@ enum PubkyService { return result.sessionAccess.exportSessionSecret() } + /// Signs in with a source-owned shared identity without persisting its secret in Bitkit's private Keychain. + static func signInSharedIdentity(secretKeyHex: String) async throws -> String { + let result = try await PaykitSdkService.shared.signIn( + secretKeyHex: secretKeyHex, + shouldStoreLocalSecret: false + ) + return result.sessionAccess.exportSessionSecret() + } + // MARK: - File Fetching /// Fetch raw bytes from a `pubky://` URI via PKDNS resolution. @@ -301,11 +290,34 @@ enum PubkyService { } static func saveContact(publicKey: String, label: String?, receiverPaths: [String]? = nil) async throws -> Paykit.ContactRecord { - try await PaykitSdkService.shared.saveContact(publicKey: publicKey, label: label, receiverPaths: receiverPaths) + try await performContactWrite( + revalidateSource: { + try await PubkyProfileManager.revalidateSharedIdentitySourceBeforeWrite() + }, + write: { + try await PaykitSdkService.shared.saveContact(publicKey: publicKey, label: label, receiverPaths: receiverPaths) + } + ) + } + + nonisolated static func performContactWrite( + revalidateSource: () async throws -> Void, + write: () async throws -> T + ) async throws -> T { + try await revalidateSource() + try Task.checkCancellation() + return try await write() } static func removeContact(publicKey: String) async throws -> Paykit.ContactRecord? { - try await PaykitSdkService.shared.removeContact(publicKey: publicKey) + try await performContactWrite( + revalidateSource: { + try await PubkyProfileManager.revalidateSharedIdentitySourceBeforeWrite() + }, + write: { + try await PaykitSdkService.shared.removeContact(publicKey: publicKey) + } + ) } static func resolveContactProfile(publicKey: String, allowPubkyProfileFallback: Bool) async throws -> Paykit.ContactProfileResolution? { @@ -325,6 +337,10 @@ enum PubkyService { static func forgetSessionAccess() async throws { try await PaykitSdkService.shared.forgetSessionAccess() } + + static func clearExternalSessionAccess() async throws { + try await PaykitSdkService.shared.clearExternalSessionAccess() + } } // MARK: - Paykit SDK Runtime @@ -350,8 +366,6 @@ actor PaykitSdkService { private var republishPublicKey: String? private var nextIdentityRepublishAt = Date.distantPast private var sdk: PaykitSdk? - private var activeAuthRequest: Paykit.PubkyAuthRequest? - private var activeAuthRequestID: UUID? init( bootstrapFactory: @escaping BootstrapFactory = PubkySessionBootstrap.withPubkyClientConfig(clientId:pubkyClient:) @@ -522,7 +536,10 @@ actor PaykitSdkService { } } - func signIn(secretKeyHex: String) async throws -> PubkySessionBootstrapResult { + func signIn( + secretKeyHex: String, + shouldStoreLocalSecret: Bool = true + ) async throws -> PubkySessionBootstrapResult { try await operationLock.withLock { let previousPublicKey = await currentSdkStatePublicKey() let receiverNoiseSecretKey = try sessionProvider.loadOrDeriveReceiverNoiseSecretKey() @@ -531,135 +548,14 @@ actor PaykitSdkService { receiverNoiseSecretKey: receiverNoiseSecretKey, requiredCapabilities: Self.requiredCapabilities() ) - try await activateBootstrapResult(result, previousPublicKey: previousPublicKey, shouldStoreLocalSecret: true) - markWalletBackupDataChanged() - return result - } - } - - func startAuth() async throws -> String { - try await operationLock.withLock { - let request = try await bootstrap().startSignInAuth(capabilities: Self.requiredCapabilities()) - let requestID = UUID() - activeAuthRequest = request - activeAuthRequestID = requestID - return try await request.authorizationUrl() - } - } - - func completeAuth() async throws -> String { - guard let request = activeAuthRequest else { - throw PubkyServiceError.invalidAuthUrl - } - guard let requestID = activeAuthRequestID else { - throw PubkyServiceError.invalidAuthUrl - } - - let result: PubkySessionBootstrapResult - do { - result = try await request.complete( - localSecretKey: nil, - receiverNoiseSecretKey: sessionProvider.loadOrDeriveReceiverNoiseSecretKey(), - requiredCapabilities: Self.requiredCapabilities() - ) - } catch { - clearActiveAuthRequest(ifCurrent: requestID) - throw error - } - - return try await operationLock.withLock { - guard activeAuthRequestID == requestID, activeAuthRequest != nil else { - throw CancellationError() - } - defer { - clearActiveAuthRequest(ifCurrent: requestID) - } - - let previousPublicKey = await currentSdkStatePublicKey() - let sessionSecret = try await Self.completeAuthActivation( - sessionSecret: result.sessionAccess.exportSessionSecret(), - activate: { - try await self.activateBootstrapResult(result, previousPublicKey: previousPublicKey, shouldStoreLocalSecret: false) - }, - discardSessionAccess: { sessionSecret in - await Task.detached { - await self.discardCompletedAuthSessionLocked(sessionSecret: sessionSecret) - }.value - } + try await activateBootstrapResult( + result, + previousPublicKey: previousPublicKey, + shouldStoreLocalSecret: shouldStoreLocalSecret ) markWalletBackupDataChanged() - return sessionSecret - } - } - - func discardCompletedAuthSession(sessionSecret: String) async { - await operationLock.withLock { - await discardCompletedAuthSessionLocked(sessionSecret: sessionSecret) - } - } - - private func discardCompletedAuthSessionLocked(sessionSecret: String) async { - let didMatchSession = await Self.discardAuthSession( - sessionSecret: sessionSecret, - storedSessionSecret: { try Keychain.loadString(key: .paykitSession) }, - revoke: { _ = try await self.handle().signOut() }, - forget: { - do { - _ = try await self.handle().forgetSessionAccess() - } catch { - try self.sessionProvider.clearSessionAccess() - throw error - } - } - ) - if didMatchSession { - resetRuntime() - markWalletBackupDataChanged() - } - } - - static func completeAuthActivation( - sessionSecret: String, - activate: () async throws -> Void, - discardSessionAccess: (String) async -> Void - ) async throws -> String { - do { - try await activate() - return sessionSecret - } catch { - await discardSessionAccess(sessionSecret) - throw error - } - } - - static func discardAuthSession( - sessionSecret: String, - storedSessionSecret: () throws -> String?, - revoke: () async throws -> Void, - forget: () async throws -> Void - ) async -> Bool { - do { - guard try storedSessionSecret() == sessionSecret else { return false } - } catch { - Logger.warn("Failed to identify abandoned Pubky session: \(error)", context: "PaykitSdkService") - return false - } - do { - try await revoke() - } catch { - Logger.warn("Failed to revoke abandoned Pubky session: \(error)", context: "PaykitSdkService") - do { - try await forget() - } catch { - Logger.warn("Failed to forget abandoned Pubky session: \(error)", context: "PaykitSdkService") - } + return result } - return true - } - - func cancelAuth() { - activeAuthRequest = nil - activeAuthRequestID = nil } func approveAuth(authUrl: String, expectedCapabilities: String, approvedClientID: String, secretKeyHex: String) async throws { @@ -1060,12 +956,22 @@ actor PaykitSdkService { func forgetSessionAccess() async throws { defer { resetRuntime() } try await withStateRevisionTracking { sdk in - activeAuthRequest = nil - activeAuthRequestID = nil _ = try await sdk.forgetSessionAccess() } } + func clearExternalSessionAccess() async throws { + try await operationLock.withLock { + try Keychain.delete(key: .paykitSession) + guard try Keychain.load(key: .paykitSession) == nil else { + throw KeychainError.failedToDelete + } + sessionProvider.clearLiveSessionAccess() + resetRuntime() + markWalletBackupDataChanged() + } + } + func clearState() async { await operationLock.withLock { clearStateLocked() @@ -1074,8 +980,6 @@ actor PaykitSdkService { private func clearStateLocked() { try? Keychain.delete(key: .paykitSdkState) - activeAuthRequest = nil - activeAuthRequestID = nil resetRuntime() markWalletBackupDataChanged() } @@ -1154,30 +1058,50 @@ actor PaykitSdkService { sdk = nil } - private func clearActiveAuthRequest(ifCurrent requestID: UUID) { - guard activeAuthRequestID == requestID else { return } - activeAuthRequest = nil - activeAuthRequestID = nil - } - private func persistSessionAccess(_ access: PubkySessionAccess, shouldStoreLocalSecret: Bool) throws { + let localSecret = access.exportLocalSecretKey() + if !shouldStoreLocalSecret, + let existingSecret = try Keychain.loadString(key: .pubkySecretKey), + !existingSecret.isEmpty + { + // An external or borrowed session must never silently replace a + // Bitkit-owned canonical identity. + throw PubkyServiceError.authFailed("A local Pubky identity already exists") + } + let localSecretHex = try Self.localSecretKeyHexForPersistence( + localSecret, + shouldStoreLocalSecret: shouldStoreLocalSecret + ) + guard let sessionData = access.exportSessionSecret().data(using: .utf8) else { throw KeychainError.failedToSave } try Keychain.upsert(key: .paykitSession, data: sessionData) try sessionProvider.persistReceiverNoiseSecretKey(access.exportReceiverNoiseSecretKey()) - guard shouldStoreLocalSecret, let localSecret = access.exportLocalSecretKey() else { - try? Keychain.delete(key: .pubkySecretKey) + guard let localSecretHex else { return } - guard let secretData = Self.secretKeyHex(from: localSecret).data(using: .utf8) else { + guard let secretData = localSecretHex.data(using: .utf8) else { throw KeychainError.failedToSave } try Keychain.upsert(key: .pubkySecretKey, data: secretData) } + static func localSecretKeyHexForPersistence( + _ localSecret: PubkyLocalSecretKey?, + shouldStoreLocalSecret: Bool + ) throws -> String? { + guard shouldStoreLocalSecret else { + return nil + } + guard let localSecret else { + throw PubkyServiceError.authFailed("Local Pubky session did not include its secret key") + } + return secretKeyHex(from: localSecret) + } + private func activateBootstrapResult( _ result: PubkySessionBootstrapResult, previousPublicKey: String?, @@ -1196,6 +1120,15 @@ actor PaykitSdkService { } private func publishReceiverMarkerIfLiveSessionAvailable(using sdk: PaykitSdk) async { + // A borrowed identity is owned by another app. Publishing a receiver marker would write + // persistent public state under the owner's namespace, bound to Bitkit's own noise key, + // and it would outlive the borrowed session once the source app goes away. + guard Self.shouldPublishReceiverMarker(loadSharedIdentityReference: { + try SharedPubkyIdentityReferenceStore.load() + }) else { + return + } + do { let capabilities = try await receiverCapabilities(using: sdk) guard capabilities.privatePayments else { return } @@ -1205,6 +1138,18 @@ actor PaykitSdkService { } } + static func shouldPublishReceiverMarker( + loadSharedIdentityReference: () throws -> SharedPubkyIdentityRefV1? + ) -> Bool { + do { + return try loadSharedIdentityReference() == nil + } catch { + // An unreadable reference may still describe a borrowed identity, so fail closed. + Logger.warn("Skipping Paykit receiver marker: shared identity state is unreadable", context: "PaykitSdkService") + return false + } + } + private func receiverCapabilities(using sdk: PaykitSdk) async throws -> Paykit.PaykitReceiverCapabilities { let status = try await sdk.identityStatus() return Paykit.PaykitReceiverCapabilities( diff --git a/Bitkit/Services/SharedPubkyIdentityVault.swift b/Bitkit/Services/SharedPubkyIdentityVault.swift new file mode 100644 index 000000000..aa5c2786f --- /dev/null +++ b/Bitkit/Services/SharedPubkyIdentityVault.swift @@ -0,0 +1,334 @@ +import Foundation +import Security + +/// A source-owned interoperability mirror. The source app's private Keychain remains canonical. +enum SharedPubkyIdentityVault { + static let service = "pubky.identity-sharing.v1" + static let sharedAccessGroupInfoKey = "SharedPubkyKeychainAccessGroup" + + static func account(source: SharedPubkyIdentitySource, pubky: String) -> String { + "\(source.rawValue):\(pubky)" + } + + static func list(source: SharedPubkyIdentitySource) throws -> [SharedPubkyIdentityRefV1] { + try references(accounts: allAccounts(), source: source) + } + + static func allAccounts() throws -> [String] { + let query: [String: Any] = try [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccessGroup as String: sharedAccessGroup(), + kSecAttrSynchronizable as String: false, + kSecReturnAttributes as String: true, + kSecMatchLimit as String: kSecMatchLimitAll, + ] + + var rawResult: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &rawResult) + if status == errSecItemNotFound { + return [] + } + try check(status) + + let attributes: [[String: Any]] + if let all = rawResult as? [[String: Any]] { + attributes = all + } else if let one = rawResult as? [String: Any] { + attributes = [one] + } else { + throw SharedPubkyIdentityError.invalidRecord + } + + return attributes.compactMap { $0[kSecAttrAccount as String] as? String } + } + + /// Loads secret data for one explicitly selected identity. Discovery never calls this. + static func loadCredential( + reference: SharedPubkyIdentityRefV1, + derivePublicKey: (String) throws -> String = { + try PubkyProfileManager.publicKeyFromSecretKey($0) + } + ) throws -> String { + guard reference.version == SharedPubkyIdentityRefV1.currentVersion else { + throw SharedPubkyIdentityError.invalidRecord + } + + let query: [String: Any] = try [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account(source: reference.sourceApp, pubky: reference.pubky), + kSecAttrAccessGroup as String: sharedAccessGroup(), + kSecAttrSynchronizable as String: false, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + + var rawResult: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &rawResult) + if status == errSecItemNotFound { + throw SharedPubkyIdentityError.sourceIdentityMissing + } + try check(status) + + guard let data = rawResult as? Data, + let record = try? JSONDecoder().decode(SharedPubkyIdentityRecordV1.self, from: data) + else { + throw SharedPubkyIdentityError.invalidRecord + } + + try validate(record: record, expected: reference, derivePublicKey: derivePublicKey) + return record.secretKey + } + + static func publishBitkitIdentity(pubky: String, secretKey: String) throws { + let reference = try SharedPubkyIdentityRefV1(sourceApp: .bitkit, pubky: pubky) + let record = SharedPubkyIdentityRecordV1( + sourceApp: .bitkit, + pubky: reference.pubky, + secretKey: secretKey + ) + try validate( + record: record, + expected: reference, + derivePublicKey: { try PubkyProfileManager.publicKeyFromSecretKey($0) } + ) + let payload = try JSONEncoder().encode(record) + let accessGroup = try sharedAccessGroup() + let itemAccount = account(source: .bitkit, pubky: reference.pubky) + + let searchQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: itemAccount, + kSecAttrAccessGroup as String: accessGroup, + kSecAttrSynchronizable as String: false, + ] + let updateStatus = SecItemUpdate( + searchQuery as CFDictionary, + [ + kSecValueData as String: payload, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + ] as CFDictionary + ) + + if updateStatus == errSecItemNotFound { + var addQuery = searchQuery + addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly + addQuery[kSecValueData as String] = payload + try check(SecItemAdd(addQuery as CFDictionary, nil)) + } else { + try check(updateStatus) + } + + let storedSecret = try loadCredential(reference: reference) + guard storedSecret == secretKey else { + throw SharedPubkyIdentityError.invalidRecord + } + + // Bitkit owns exactly one local identity. Only prune stale Bitkit-owned + // accounts after the current mirror has been written and verified. + try pruneStaleBitkitIdentities( + keeping: itemAccount, + listAccounts: { try allAccounts() }, + deleteAccount: { try deleteOwnedAccount($0) } + ) + } + + static func deleteBitkitIdentity(pubky: String) throws { + guard let normalizedPubky = SharedPubkyKeyFormat.normalizedBare(pubky) else { + throw SharedPubkyIdentityError.invalidPublicKey + } + + try deleteOwnedBitkitIdentities( + including: account(source: .bitkit, pubky: normalizedPubky), + listAccounts: { try allAccounts() }, + deleteAccount: { try deleteOwnedAccount($0) } + ) + } + + static func deleteAllBitkitIdentities() throws { + try deleteOwnedBitkitIdentities( + including: nil, + listAccounts: { try allAccounts() }, + deleteAccount: { try deleteOwnedAccount($0) } + ) + } + + /// Erases every Bitkit-owned mirror, not just the current one: a reconciliation that failed + /// while pruning can leave an older owned account behind, and destructive flows delete the + /// canonical private key as soon as this returns. + static func deleteOwnedBitkitIdentities( + including currentAccount: String?, + listAccounts: () throws -> [String], + deleteAccount: (String) throws -> Void + ) throws { + if let currentAccount { + try deleteAccount(currentAccount) + } + + for staleAccount in try ownedAccounts(accounts: listAccounts(), source: .bitkit) + where staleAccount != currentAccount + { + try deleteAccount(staleAccount) + } + + guard try ownedAccounts(accounts: listAccounts(), source: .bitkit).isEmpty else { + throw SharedPubkyIdentityError.invalidRecord + } + } + + static func pruneStaleBitkitIdentities( + keeping currentAccount: String, + listAccounts: () throws -> [String], + deleteAccount: (String) throws -> Void + ) throws { + let ownedBeforePruning = try ownedAccounts(accounts: listAccounts(), source: .bitkit) + guard ownedBeforePruning.contains(currentAccount) else { + throw SharedPubkyIdentityError.invalidRecord + } + + for staleAccount in ownedBeforePruning where staleAccount != currentAccount { + try deleteAccount(staleAccount) + } + + guard try ownedAccounts(accounts: listAccounts(), source: .bitkit) == [currentAccount] else { + throw SharedPubkyIdentityError.invalidRecord + } + } + + static func ownedAccounts( + accounts: [String], + source: SharedPubkyIdentitySource + ) -> [String] { + let prefix = "\(source.rawValue):" + return Array(Set(accounts.filter { $0.hasPrefix(prefix) })).sorted() + } + + static func references( + accounts: [String], + source: SharedPubkyIdentitySource + ) -> [SharedPubkyIdentityRefV1] { + let prefix = "\(source.rawValue):" + var seen = Set() + + return ownedAccounts(accounts: accounts, source: source).compactMap { value -> SharedPubkyIdentityRefV1? in + let wirePubky = String(value.dropFirst(prefix.count)) + guard SharedPubkyKeyFormat.normalizedBare(wirePubky) == wirePubky, + let reference = try? SharedPubkyIdentityRefV1( + sourceApp: source, + pubky: wirePubky + ), + seen.insert(reference.pubky).inserted + else { + return nil + } + return reference + } + .sorted { $0.pubky < $1.pubky } + } + + static func validate( + record: SharedPubkyIdentityRecordV1, + expected: SharedPubkyIdentityRefV1, + derivePublicKey: (String) throws -> String + ) throws { + guard record.version == SharedPubkyIdentityRecordV1.currentVersion, + expected.version == SharedPubkyIdentityRefV1.currentVersion, + record.sourceApp == expected.sourceApp, + record.pubky == expected.pubky, + SharedPubkyKeyFormat.isCanonicalSecretKey(record.secretKey) + else { + throw SharedPubkyIdentityError.invalidRecord + } + + let derivedPubky: String + do { + derivedPubky = try derivePublicKey(record.secretKey) + } catch { + throw SharedPubkyIdentityError.invalidRecord + } + guard SharedPubkyKeyFormat.normalizedBare(derivedPubky) == expected.pubky else { + throw SharedPubkyIdentityError.secretDoesNotMatchPublicKey + } + } + + static func sharedAccessGroup(bundle: Bundle = .main) throws -> String { + guard let value = bundle.object(forInfoDictionaryKey: sharedAccessGroupInfoKey) as? String, + !value.isEmpty, + !value.contains("$(") + else { + throw SharedPubkyIdentityError.unavailable + } + return value + } + + static func error(for status: OSStatus) -> SharedPubkyIdentityError? { + switch status { + case noErr: + return nil + case errSecInteractionNotAllowed, errSecNotAvailable: + return .temporarilyUnavailable + case errSecMissingEntitlement: + return .missingEntitlement + default: + return .unavailable + } + } + + private static func check(_ status: OSStatus) throws { + guard let error = error(for: status) else { return } + + Logger.warn("Shared Pubky Keychain operation failed with status \(status)", context: "SharedPubkyIdentityVault") + throw error + } + + private static func deleteOwnedAccount(_ itemAccount: String) throws { + guard ownedAccounts(accounts: [itemAccount], source: .bitkit) == [itemAccount] else { + throw SharedPubkyIdentityError.invalidRecord + } + + let query: [String: Any] = try [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: itemAccount, + kSecAttrAccessGroup as String: sharedAccessGroup(), + kSecAttrSynchronizable as String: false, + ] + let status = SecItemDelete(query as CFDictionary) + guard status == noErr || status == errSecItemNotFound else { + try check(status) + return + } + } +} + +enum SharedPubkyIdentityReferenceStore { + static func load() throws -> SharedPubkyIdentityRefV1? { + guard let data = try Keychain.load(key: .sharedPubkyIdentityReference) else { + return nil + } + + guard let reference = try? JSONDecoder().decode(SharedPubkyIdentityRefV1.self, from: data), + reference.version == SharedPubkyIdentityRefV1.currentVersion, + SharedPubkyKeyFormat.normalizedBare(reference.pubky) == reference.pubky + else { + throw SharedPubkyIdentityError.invalidRecord + } + return reference + } + + static func save(_ reference: SharedPubkyIdentityRefV1) throws { + try Keychain.upsert( + key: .sharedPubkyIdentityReference, + data: JSONEncoder().encode(reference) + ) + } + + static func delete() throws { + try Keychain.delete(key: .sharedPubkyIdentityReference) + guard try Keychain.load(key: .sharedPubkyIdentityReference) == nil else { + throw KeychainError.failedToDelete + } + } +} diff --git a/Bitkit/Styles/Colors.swift b/Bitkit/Styles/Colors.swift index adb58e7d4..23fe8d430 100644 --- a/Bitkit/Styles/Colors.swift +++ b/Bitkit/Styles/Colors.swift @@ -9,7 +9,7 @@ extension Color { static let purpleAccent = Color(hex: 0xB95CE8) static let redAccent = Color(hex: 0xE95164) static let yellowAccent = Color(hex: 0xFFD200) - static let pubkyGreen = Color(hex: 0xBEFF00) + static let pubkyGreen = Color(hex: 0xC8FF00) static let bitcoin = Color(hex: 0xF7931A) // MARK: - Base diff --git a/Bitkit/Styles/TextStyle.swift b/Bitkit/Styles/TextStyle.swift index 574999f72..716b0b706 100644 --- a/Bitkit/Styles/TextStyle.swift +++ b/Bitkit/Styles/TextStyle.swift @@ -169,19 +169,22 @@ struct BodyMText: View { var accentAction: (() -> Void)? private let fontSize: CGFloat = 17 + private let kerningValue: CGFloat init( _ text: String, textColor: Color = .textSecondary, accentColor: Color = .white, accentFont: ((CGFloat) -> Font)? = nil, - accentAction: (() -> Void)? = nil + accentAction: (() -> Void)? = nil, + kerning: CGFloat = 0.4 ) { self.text = text self.textColor = textColor self.accentColor = accentColor self.accentFont = accentFont self.accentAction = accentAction + kerningValue = kerning } var body: some View { @@ -193,7 +196,7 @@ struct BodyMText: View { accentFont: accentFont?(fontSize), accentAction: accentAction ) - .kerning(0.4) + .kerning(kerningValue) } } @@ -205,19 +208,22 @@ struct BodyMSBText: View { var accentAction: (() -> Void)? private let fontSize: CGFloat = 17 + private let kerningValue: CGFloat init( _ text: String, textColor: Color = .textPrimary, accentColor: Color = .brandAccent, accentFont: ((CGFloat) -> Font)? = nil, - accentAction: (() -> Void)? = nil + accentAction: (() -> Void)? = nil, + kerning: CGFloat = 0.4 ) { self.text = text self.textColor = textColor self.accentColor = accentColor self.accentFont = accentFont self.accentAction = accentAction + kerningValue = kerning } var body: some View { @@ -229,7 +235,7 @@ struct BodyMSBText: View { accentFont: accentFont?(fontSize), accentAction: accentAction ) - .kerning(0.4) + .kerning(kerningValue) } } @@ -313,19 +319,22 @@ struct BodySSBText: View { var accentAction: (() -> Void)? private let fontSize: CGFloat = 15 + private let kerningValue: CGFloat init( _ text: String, textColor: Color = .textPrimary, accentColor: Color = .brandAccent, accentFont: ((CGFloat) -> Font)? = nil, - accentAction: (() -> Void)? = nil + accentAction: (() -> Void)? = nil, + kerning: CGFloat = 0.4 ) { self.text = text self.textColor = textColor self.accentColor = accentColor self.accentFont = accentFont self.accentAction = accentAction + kerningValue = kerning } var body: some View { @@ -337,7 +346,7 @@ struct BodySSBText: View { accentFont: accentFont?(fontSize), accentAction: accentAction ) - .kerning(0.4) + .kerning(kerningValue) // .lineSpacing(0) } } diff --git a/Bitkit/Utilities/AppReset.swift b/Bitkit/Utilities/AppReset.swift index 617cd3e4f..3423ad8ff 100644 --- a/Bitkit/Utilities/AppReset.swift +++ b/Bitkit/Utilities/AppReset.swift @@ -9,6 +9,26 @@ enum AppReset { session: SessionManager, toastType: Toast.ToastType = .success ) async throws { + try await PubkyProfileManager.withIdentityLifecycleLock { + try await wipeLocked( + app: app, + wallet: wallet, + session: session, + toastType: toastType + ) + } + } + + @MainActor + private static func wipeLocked( + app: AppViewModel, + wallet: WalletViewModel, + session: SessionManager, + toastType: Toast.ToastType + ) async throws { + // Shared mirrors must be gone before any server or canonical private source is cleared. + try SharedPubkyIdentityVault.deleteAllBitkitIdentities() + await PubkyProfileManager.removePublicPaykitEndpointsBestEffort(context: "AppReset.wipe") await PubkyProfileManager.removePrivatePaykitEndpointsBestEffort(context: "AppReset.wipe") @@ -70,6 +90,9 @@ enum AppReset { title: t("security__wiped_title"), description: t("security__wiped_message") ) + + // Re-verify while the identity lifecycle gate still excludes reconciliation. + try SharedPubkyIdentityVault.deleteAllBitkitIdentities() } private static func wipeLogs() throws { diff --git a/Bitkit/Utilities/Keychain.swift b/Bitkit/Utilities/Keychain.swift index 584e60e17..3e06ad170 100644 --- a/Bitkit/Utilities/Keychain.swift +++ b/Bitkit/Utilities/Keychain.swift @@ -13,6 +13,7 @@ enum KeychainEntryType { case paykitReceiverNoiseSecretKey case paykitSdkState case pubkySecretKey + case sharedPubkyIdentityReference var storageKey: String { switch self { @@ -27,6 +28,7 @@ enum KeychainEntryType { case .paykitReceiverNoiseSecretKey: "paykit_receiver_noise_secret_key" case .paykitSdkState: "paykit_sdk_state" case .pubkySecretKey: "pubky_secret_key" + case .sharedPubkyIdentityReference: "shared_pubky_identity_reference_v1" } } } @@ -223,6 +225,7 @@ class Keychain { class func getAllKeyChainStorageKeys() -> [String] { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, + kSecAttrAccessGroup as String: Env.keychainGroup, kSecReturnData as String: kCFBooleanTrue!, kSecReturnAttributes as String: kCFBooleanTrue!, kSecReturnRef as String: kCFBooleanTrue!, diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 32ff7ac06..4bc6e34eb 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -984,9 +984,12 @@ extension AppViewModel { return } - guard let secretKey = try? Keychain.loadString(key: .pubkySecretKey), - !secretKey.isEmpty - else { + let hasLocalSecret = (try? Keychain.loadString(key: .pubkySecretKey))?.isEmpty == false + let hasSharedSource = (try? SharedPubkyIdentityReferenceStore.load()) != nil + && PubkyProfileManager.isRingAvailable() + + // A source-owned identity can approve after retrieving its key just in time. + guard hasLocalSecret || hasSharedSource else { sheetViewModel.hideSheetIfActive(.scanner, reason: "Pubky identity requires Ring") toast(type: .info, title: t("pubky_auth__use_ring"), description: t("pubky_auth__use_ring_desc")) return diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index e104ec232..376ed84f5 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -70,6 +70,29 @@ class WalletViewModel: ObservableObject { PaykitFeatureFlags.isUIAvailable && isPaykitUIEnabled } + private(set) var isPaykitMaintenanceAllowed = false + private var pendingChannelUsableRefresh = false + + private var isPaykitMaintenanceEnabled: Bool { + isPaykitUIActive && isPaykitMaintenanceAllowed + } + + func setPaykitMaintenanceAllowed(_ isAllowed: Bool) { + let wasAllowed = isPaykitMaintenanceAllowed + isPaykitMaintenanceAllowed = isAllowed + guard isAllowed, !wasAllowed else { return } + + let hasUsableChannels = channels?.contains(where: \.isUsable) == true + if Self.shouldRefreshPaykitAfterChannelChange( + allowPaykitMaintenance: true, + hadUsableChannels: hasUsableChannels, + hasUsableChannels: hasUsableChannels, + pendingRefresh: &pendingChannelUsableRefresh + ) { + schedulePaykitChannelUsabilityRefresh() + } + } + private let lightningService: LightningService private let coreService: CoreService private let electrumConfigService: ElectrumConfigService @@ -207,11 +230,11 @@ class WalletViewModel: ObservableObject { ) case let .paymentReceived(_, paymentHash, _, _): self.bolt11 = "" - if self.isPaykitUIActive { + if self.isPaykitMaintenanceEnabled { self.rotatePublicPaykitInvoiceIfNeeded(paymentHash: paymentHash) } Task { - if self.isPaykitUIActive { + if self.isPaykitMaintenanceEnabled { await PrivatePaykitService.shared.handleReceivedPayment(paymentHash: paymentHash, wallet: self) } await self.refreshAndSyncState() @@ -240,7 +263,7 @@ class WalletViewModel: ObservableObject { await self.refreshAndSyncState() await self.handleChannelClosed(channelId: channelId, reason: reason) try? await self.refreshBip21() - if self.isPaykitUIActive { + if self.isPaykitMaintenanceEnabled { await PrivatePaykitService.shared.refreshKnownSavedContactEndpoints(wallet: self, reason: "channel-closed refresh") } } @@ -250,7 +273,7 @@ class WalletViewModel: ObservableObject { case let .onchainTransactionReceived(_, details): Task { await self.refreshAndSyncState() - if self.isPaykitUIActive { + if self.isPaykitMaintenanceEnabled { await PrivatePaykitService.shared.handleOnchainActivity( receivedAddresses: details.outputs.compactMap(\.scriptpubkeyAddress), wallet: self @@ -261,7 +284,7 @@ class WalletViewModel: ObservableObject { case let .onchainTransactionConfirmed(_, _, _, _, details): Task { await self.refreshAndSyncState() - if self.isPaykitUIActive { + if self.isPaykitMaintenanceEnabled { await PrivatePaykitService.shared.handleOnchainActivity( receivedAddresses: details.outputs.compactMap(\.scriptpubkeyAddress), wallet: self @@ -272,7 +295,7 @@ class WalletViewModel: ObservableObject { case .onchainTransactionReplaced, .onchainTransactionReorged, .onchainTransactionEvicted: Task { await self.refreshAndSyncState() - if self.isPaykitUIActive { + if self.isPaykitMaintenanceEnabled { await PrivatePaykitService.shared.handleOnchainActivity(wallet: self) } } @@ -558,8 +581,8 @@ class WalletViewModel: ObservableObject { return nodeLifecycleState == .running } - func sync() async throws { - syncState() + func sync(allowPaykitMaintenance: Bool = true) async throws { + syncState(allowPaykitMaintenance: allowPaykitMaintenance) if isSyncingWallet { Logger.warn("Sync already in progress, waiting for existing sync.") @@ -574,7 +597,7 @@ class WalletViewModel: ObservableObject { } isSyncingWallet = true - syncState() + syncState(allowPaykitMaintenance: allowPaykitMaintenance) do { try await lightningService.sync() @@ -584,9 +607,9 @@ class WalletViewModel: ObservableObject { } isSyncingWallet = false - syncState() + syncState(allowPaykitMaintenance: allowPaykitMaintenance) QuickPayPaymentCoordinator.shared.reconcileAgainstLdk() - if isPaykitUIActive { + if isPaykitMaintenanceEnabled, allowPaykitMaintenance { await PrivatePaykitService.shared.reconcileReceivedPayments(wallet: self) await PrivatePaykitService.shared.handleOnchainActivity(wallet: self) } @@ -996,9 +1019,9 @@ class WalletViewModel: ObservableObject { /// Sync all state (node status, channels, peers, balances) /// Use this for initial load or after sync operations /// Note: Uses cached values from LightningService - call syncStateAsync() for fresh data - func syncState() { + func syncState(allowPaykitMaintenance: Bool = true) { syncNodeStatus() - syncChannelsAndPeers() + syncChannelsAndPeers(allowPaykitMaintenance: allowPaykitMaintenance && isPaykitMaintenanceAllowed) syncBalances() } @@ -1121,7 +1144,7 @@ class WalletViewModel: ObservableObject { } /// Sync channels and peers only - private func syncChannelsAndPeers() { + private func syncChannelsAndPeers(allowPaykitMaintenance: Bool = true) { let hadUsableChannels = channels?.contains(where: \.isUsable) ?? false peers = lightningService.peers channels = lightningService.channels @@ -1131,14 +1154,39 @@ class WalletViewModel: ObservableObject { channelCount = channels.count } - if hasUsableChannels, !hadUsableChannels { - Task { [weak self] in - await self?.refreshPaykitEndpointsAfterChannelAvailabilityChanged( - reason: "channel-usable refresh", - forceRefreshLightning: true - ) - } + if Self.shouldRefreshPaykitAfterChannelChange( + allowPaykitMaintenance: allowPaykitMaintenance, + hadUsableChannels: hadUsableChannels, + hasUsableChannels: hasUsableChannels, + pendingRefresh: &pendingChannelUsableRefresh + ) { + schedulePaykitChannelUsabilityRefresh() + } + } + + private func schedulePaykitChannelUsabilityRefresh() { + Task { [weak self] in + await self?.refreshPaykitEndpointsAfterChannelAvailabilityChanged( + reason: "channel-usable refresh", + forceRefreshLightning: true + ) + } + } + + static func shouldRefreshPaykitAfterChannelChange( + allowPaykitMaintenance: Bool, + hadUsableChannels: Bool, + hasUsableChannels: Bool, + pendingRefresh: inout Bool + ) -> Bool { + guard hasUsableChannels else { return false } + guard allowPaykitMaintenance else { + pendingRefresh = pendingRefresh || !hadUsableChannels + return false } + let shouldRefresh = pendingRefresh || !hadUsableChannels + pendingRefresh = false + return shouldRefresh } /// Sync balance details only @@ -1270,7 +1318,7 @@ class WalletViewModel: ObservableObject { includeOnchain: Bool = true, includeLightning: Bool = true ) async throws -> (onchainAddress: String, bolt11: String) { - guard isPaykitUIActive else { + guard isPaykitMaintenanceEnabled else { return ("", "") } @@ -1297,7 +1345,7 @@ class WalletViewModel: ObservableObject { } func refreshPublicPaykitEndpointsOnForeground() async { - guard isPaykitUIActive, sharesPublicPaykitEndpoints else { return } + guard isPaykitMaintenanceEnabled, sharesPublicPaykitEndpoints else { return } do { try await PublicPaykitService.syncCurrentPublishedEndpoints(wallet: self) @@ -1307,7 +1355,7 @@ class WalletViewModel: ObservableObject { } private func syncPublicPaykitEndpointsAfterChannelBecameUsable() async { - guard isPaykitUIActive else { return } + guard isPaykitMaintenanceEnabled else { return } do { try await PublicPaykitService.syncPublishedEndpoints(wallet: self, publish: true) @@ -1320,7 +1368,7 @@ class WalletViewModel: ObservableObject { await refreshAndSyncState() try? await refreshBip21(forceRefreshBolt11: forceRefreshLightning) - guard isPaykitUIActive else { return } + guard isPaykitMaintenanceEnabled else { return } if sharesPublicPaykitEndpoints { do { @@ -1436,7 +1484,7 @@ class WalletViewModel: ObservableObject { // Persist metadata with migrated tags await persistPreActivityMetadata(tags: tagsToMigrate) - if isPaykitUIActive, sharesPublicPaykitEndpoints { + if isPaykitMaintenanceEnabled, sharesPublicPaykitEndpoints { do { try await PublicPaykitService.syncCurrentPublishedEndpoints(wallet: self) } catch { diff --git a/Bitkit/Views/Contacts/ContactImportOverviewView.swift b/Bitkit/Views/Contacts/ContactImportOverviewView.swift index 7f5ea112a..b95340787 100644 --- a/Bitkit/Views/Contacts/ContactImportOverviewView.swift +++ b/Bitkit/Views/Contacts/ContactImportOverviewView.swift @@ -19,7 +19,11 @@ struct ContactImportOverviewView: View { var body: some View { VStack(spacing: 0) { - NavigationBar(title: t("contacts__import_nav_title")) + NavigationBar(title: "") + .overlay { + TitleText(t("contacts__import_nav_title")) + .allowsHitTesting(false) + } .padding(.horizontal, 16) ScrollView { @@ -28,19 +32,20 @@ struct ContactImportOverviewView: View { t("contacts__import_found_title"), accentColor: .pubkyGreen ) - .padding(.top, 24) - .padding(.bottom, 8) + .padding(.top, 20) + .padding(.bottom, 14) BodyMText( t("contacts__import_found_description", variables: ["key": profile.truncatedPublicKey]), accentColor: .white, - accentFont: Fonts.bold + accentFont: Fonts.bold, + kerning: 0 ) .fixedSize(horizontal: false, vertical: true) - .padding(.bottom, 32) + .padding(.bottom, 33.5) profileRow - .padding(.bottom, 24) + .padding(.bottom, 32) contactsSummary } @@ -49,7 +54,7 @@ struct ContactImportOverviewView: View { Spacer() - BottomActionBar { + BottomActionBar(bottomPadding: 0) { buttonBar } } @@ -62,7 +67,7 @@ struct ContactImportOverviewView: View { // MARK: - Profile Row private var profileRow: some View { - HStack(alignment: .top, spacing: 16) { + HStack(alignment: .center, spacing: 16) { HeadlineText(profile.name) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) @@ -83,7 +88,10 @@ struct ContactImportOverviewView: View { private var contactsSummary: some View { HStack(spacing: 16) { - BodyMSBText(t("contacts__import_friends_count", variables: ["count": "\(contacts.count)"])) + BodyMSBText( + t("contacts__import_friends_count", variables: ["count": "\(contacts.count)"]), + kerning: 0 + ) Spacer() @@ -151,14 +159,19 @@ struct ContactImportOverviewView: View { private var buttonBar: some View { HStack(spacing: 16) { - CustomButton(title: t("contacts__import_select"), variant: .secondary) { + CustomButton( + title: t("contacts__import_select"), + variant: .secondary, + labelKerning: 0 + ) { navigation.navigate(.contactImportSelect) } .accessibilityIdentifier("ContactImportOverviewSelect") CustomButton( title: t("contacts__import_all"), - isLoading: isImporting + isLoading: isImporting, + labelKerning: 0 ) { await importAllContacts() } diff --git a/Bitkit/Views/Profile/EditProfileView.swift b/Bitkit/Views/Profile/EditProfileView.swift index 4876a5b39..de657c523 100644 --- a/Bitkit/Views/Profile/EditProfileView.swift +++ b/Bitkit/Views/Profile/EditProfileView.swift @@ -175,8 +175,11 @@ struct EditProfileView: View { } private func performDeleteProfile() async throws { - await contactsManager.deleteAllContactsBestEffort() - try await pubkyProfile.deleteProfile() + try await PubkyProfileManager.deleteProfileWithContactCleanup( + revalidateSource: { try await pubkyProfile.ensureSharedIdentitySourceIsValid() }, + deleteContacts: { await contactsManager.deleteAllContactsBestEffort() }, + deleteProfile: { try await pubkyProfile.deleteProfile() } + ) navigation.path = [app.hasSeenProfileIntro ? .pubkyChoice : .profileIntro] } diff --git a/Bitkit/Views/Profile/PubkyChoiceView.swift b/Bitkit/Views/Profile/PubkyChoiceView.swift index 026b37af4..5ef556c62 100644 --- a/Bitkit/Views/Profile/PubkyChoiceView.swift +++ b/Bitkit/Views/Profile/PubkyChoiceView.swift @@ -1,37 +1,36 @@ import SwiftUI struct PubkyChoiceView: View { - @EnvironmentObject var app: AppViewModel - @EnvironmentObject var navigation: NavigationViewModel - @EnvironmentObject var pubkyProfile: PubkyProfileManager - @EnvironmentObject var contactsManager: ContactsManager - @Environment(\.scenePhase) var scenePhase + @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var navigation: NavigationViewModel + @EnvironmentObject private var pubkyProfile: PubkyProfileManager + @EnvironmentObject private var contactsManager: ContactsManager + @Environment(\.scenePhase) private var scenePhase - @State private var isAuthenticating = false - @State private var isWaitingForRing = false - @State private var isLoadingAfterAuth = false - @State private var showRingNotInstalledDialog = false - - private let pubkyRingAppStoreUrl = "https://apps.apple.com/app/pubky-ring/id6739356756" + @State private var selectedPubky: String? var body: some View { ZStack { backgroundIllustrations VStack(spacing: 0) { - NavigationBar(title: t("profile__nav_title")) + NavigationBar(title: "") + .overlay { + TitleText(t("profile__nav_title")) + .allowsHitTesting(false) + } .padding(.horizontal, 16) - VStack(alignment: .leading, spacing: 0) { - titleSection - .padding(.top, 24) - .padding(.bottom, 24) + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + titleSection + .padding(.top, 20) + .padding(.bottom, 33) - optionCards + optionCards + } + .padding(.horizontal, 16) } - .padding(.horizontal, 16) - - Spacer() } } .clipped() @@ -39,38 +38,19 @@ struct PubkyChoiceView: View { .bottomSafeAreaPadding() .background(Color.customBlack) .navigationBarHidden(true) - .task(id: isWaitingForRing) { - guard isWaitingForRing else { return } - await waitForApproval() + .task { + await pubkyProfile.refreshSharedRingIdentities() } .onChange(of: scenePhase) { _, newPhase in - if newPhase == .active, isWaitingForRing { - // Ring returned to app — approval task handles completion + guard newPhase == .active else { return } + Task { + await pubkyProfile.refreshSharedRingIdentities() } } - .onChange(of: pubkyProfile.authState) { _, authState in - authState.resetRingAuthViewStateIfNeeded( - isAuthenticating: $isAuthenticating, - isWaitingForRing: $isWaitingForRing, - isLoadingAfterAuth: $isLoadingAfterAuth - ) - } - .alert(t("profile__ring_not_installed_title"), isPresented: $showRingNotInstalledDialog) { - Button(t("profile__ring_download")) { - if let url = URL(string: pubkyRingAppStoreUrl) { - Task { await UIApplication.shared.open(url) } - } - } - Button(t("common__dialog_cancel"), role: .cancel) {} - } message: { - Text(t("profile__ring_not_installed_description")) - } } - // MARK: - Title Section - private var titleSection: some View { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 13.5) { DisplayText( t("profile__choice_title"), accentColor: .pubkyGreen @@ -78,77 +58,50 @@ struct PubkyChoiceView: View { .frame(maxWidth: .infinity, alignment: .leading) .fixedSize(horizontal: false, vertical: true) - BodyMText(isLoadingAfterAuth - ? t("profile__ring_loading") - : isWaitingForRing ? t("profile__ring_waiting") : t("profile__choice_description")) - .frame(maxWidth: .infinity, alignment: .leading) - .fixedSize(horizontal: false, vertical: true) + BodyMText( + t(pubkyProfile.sharedRingIdentities.isEmpty + ? "profile__choice_description" + : "profile__choice_description_existing"), + kerning: 0 + ) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) } } - // MARK: - Option Cards - private var optionCards: some View { VStack(spacing: 8) { - choiceCard( - icon: "user-plus", - title: t("profile__choice_create"), - accessibilityId: "PubkyChoiceCreate" - ) { - navigation.navigate(.createProfile) - } - .disabled(isAuthenticating || isWaitingForRing || isLoadingAfterAuth) - - if isWaitingForRing || isLoadingAfterAuth { - ringWaitingCard + if pubkyProfile.sharedRingIdentities.isEmpty { + switch pubkyProfile.sharedRingIdentityDiscoveryState { + case .initial: + EmptyView() + case .loading: + discoveryLoadingCard + case .loaded: + createCard + case .unavailable: + discoveryErrorCard + } } else { - choiceCard( - systemIcon: "key.fill", - title: t("profile__choice_import"), - isLoading: isAuthenticating, - accessibilityId: "PubkyChoiceImport" - ) { - await startRingAuth() + ForEach(pubkyProfile.sharedRingIdentities) { identity in + sharedIdentityCard(identity) } - .disabled(isAuthenticating) } } } - private func choiceCard( - icon: String? = nil, - systemIcon: String? = nil, - title: String, - isLoading: Bool = false, - accessibilityId: String, - action: @escaping () async -> Void - ) -> some View { + private var createCard: some View { Button { - Task { await action() } + navigation.navigate(.createProfile) } label: { HStack(spacing: 16) { - ZStack { - Circle() - .fill(Color.black) - .frame(width: 40, height: 40) + cardIcon - if isLoading { - ActivityIndicator(size: 20) - } else if let icon { - Image(icon) - .resizable() - .scaledToFit() - .foregroundColor(.pubkyGreen) - .frame(width: 20, height: 20) - } else if let systemIcon { - Image(systemName: systemIcon) - .font(.system(size: 16, weight: .semibold)) - .foregroundColor(.pubkyGreen) - } + VStack(alignment: .leading, spacing: 2) { + CaptionMText(t("profile__choice_new_pubky"), textColor: .white64) + BodyMSBText(t("profile__choice_create"), textColor: .white) } - BodyMSBText(title, textColor: .white) - Spacer() } .padding(.horizontal, 16) @@ -156,95 +109,143 @@ struct PubkyChoiceView: View { .background(Color.gray6) .cornerRadius(16) } - .accessibilityIdentifier(accessibilityId) + .buttonStyle(.plain) + .disabled(selectedPubky != nil) + .accessibilityIdentifier("PubkyChoiceCreate") } - // MARK: - Ring Auth + private func sharedIdentityCard(_ identity: SharedPubkyIdentityOption) -> some View { + Button { + Task { + await useSharedIdentity(identity) + } + } label: { + HStack(spacing: 16) { + cardKeyIcon + + VStack(alignment: .leading, spacing: 2) { + CaptionMText( + PubkyPublicKeyFormat.displayTruncated(identity.reference.pubky), + textColor: .white64 + ) + BodyMSBText(identity.profile.name, textColor: .white) + .lineLimit(1) + } - private func startRingAuth() async { - isAuthenticating = true + Spacer(minLength: 8) - do { - try await pubkyProfile.startAuthentication() - isAuthenticating = false - isWaitingForRing = true - } catch PubkyServiceError.ringNotInstalled { - isAuthenticating = false - showRingNotInstalledDialog = true - } catch { - isAuthenticating = false - app.toast(type: .error, title: t("profile__auth_error_title"), description: error.localizedDescription) + if selectedPubky == identity.reference.pubky { + ActivityIndicator(size: 24) + .frame(width: 40, height: 40) + } else { + PubkyContactAvatar( + name: identity.profile.name, + imageUrl: identity.profile.imageUrl, + size: 32 + ) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 16) + .background(Color.gray6) + .cornerRadius(16) } + .buttonStyle(.plain) + .disabled(selectedPubky != nil) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("PubkyChoiceShared_\(identity.reference.pubky)") } - private func waitForApproval() async { - do { - let publicKey = try await pubkyProfile.completeAuthentication() - isLoadingAfterAuth = true - await navigateAfterAuth(publicKey: publicKey) - } catch is CancellationError { - return - } catch { - isWaitingForRing = false - app.toast(type: .error, title: t("profile__auth_error_title"), description: error.localizedDescription) + private var discoveryLoadingCard: some View { + HStack(spacing: 16) { + cardKeyIcon + ActivityIndicator(size: 20) + BodyMSBText(t("profile__ring_loading"), textColor: .white64) + Spacer() } + .padding(.horizontal, 16) + .padding(.vertical, 16) + .background(Color.gray6) + .cornerRadius(16) + .accessibilityIdentifier("PubkyChoiceSharedLoading") } - private func navigateAfterAuth(publicKey: String) async { - let destination = await contactsManager.destinationAfterAuthentication( - profile: pubkyProfile.profile, - publicKey: publicKey - ) - navigation.path = [destination] - pubkyProfile.finalizeAuthentication() + private var discoveryErrorCard: some View { + VStack(alignment: .leading, spacing: 12) { + BodyMText(t("profile__ring_discovery_error"), textColor: .white64) + .fixedSize(horizontal: false, vertical: true) + + CustomButton(title: t("common__retry"), variant: .secondary) { + await pubkyProfile.refreshSharedRingIdentities() + } + } + .padding(16) + .background(Color.gray6) + .cornerRadius(16) + .accessibilityIdentifier("PubkyChoiceSharedError") } - // MARK: - Ring Waiting Card + private var cardIcon: some View { + ZStack { + Circle() + .fill(Color.black) + .frame(width: 40, height: 40) - private var ringWaitingCard: some View { - VStack(spacing: 12) { - HStack(spacing: 16) { - ZStack { - Circle() - .fill(Color.black) - .frame(width: 40, height: 40) + Image("user-plus") + .resizable() + .scaledToFit() + .foregroundColor(.pubkyGreen) + .frame(width: 20, height: 20) + } + } - ActivityIndicator(size: 20) - } + private var cardKeyIcon: some View { + ZStack { + Circle() + .fill(Color.black) + .frame(width: 40, height: 40) - BodyMSBText(t(isLoadingAfterAuth ? "profile__ring_loading" : "profile__ring_waiting"), textColor: .white) + Image("key") + .resizable() + .scaledToFit() + .foregroundColor(.pubkyGreen) + .frame(width: 20, height: 20) + } + } - Spacer() - } + private func useSharedIdentity(_ identity: SharedPubkyIdentityOption) async { + guard selectedPubky == nil else { return } + selectedPubky = identity.reference.pubky + defer { selectedPubky = nil } - if !isLoadingAfterAuth { - Button { - isWaitingForRing = false - Task { await pubkyProfile.cancelAuthentication() } - } label: { - BodySSBText(t("common__cancel"), textColor: .white64) - } - .frame(maxWidth: .infinity, alignment: .trailing) - .accessibilityIdentifier("PubkyChoiceCancelRing") - } + do { + let publicKey = try await pubkyProfile.useSharedRingIdentity(identity) + let destination = await contactsManager.destinationAfterAuthentication( + profile: pubkyProfile.profile, + publicKey: publicKey + ) + navigation.path = [destination] + pubkyProfile.finalizeAuthentication() + } catch { + Logger.warn("Failed to use shared Pubky Ring identity: \(error)", context: "PubkyChoiceView") + app.toast( + type: .error, + title: t("profile__auth_error_title"), + description: error.localizedDescription + ) + await pubkyProfile.refreshSharedRingIdentities() } - .padding(.horizontal, 16) - .padding(.vertical, 16) - .background(Color.gray6) - .cornerRadius(16) } - // MARK: - Background Illustrations - private var backgroundIllustrations: some View { GeometryReader { geo in Image("tag-pubky") .resizable() .scaledToFit() - .frame(width: geo.size.width * 0.83) + .frame(width: geo.size.width * 0.64) .position( - x: geo.size.width * 0.321, - y: geo.size.height * 0.376 + 200 + x: geo.size.width * 0.187, + y: geo.size.height * 0.376 + 364 ) Image("keyring") @@ -253,8 +254,8 @@ struct PubkyChoiceView: View { .frame(width: geo.size.width * 0.83) .opacity(0.9) .position( - x: geo.size.width * 0.841, - y: geo.size.height * 0.305 + 200 + x: geo.size.width * 0.751, + y: geo.size.height * 0.305 + 370 ) } .ignoresSafeArea() diff --git a/Bitkit/Views/Profile/PubkyRingAuthView.swift b/Bitkit/Views/Profile/PubkyRingAuthView.swift deleted file mode 100644 index a4f956e8f..000000000 --- a/Bitkit/Views/Profile/PubkyRingAuthView.swift +++ /dev/null @@ -1,210 +0,0 @@ -import SwiftUI - -struct PubkyRingAuthView: View { - @EnvironmentObject var app: AppViewModel - @EnvironmentObject var navigation: NavigationViewModel - @EnvironmentObject var pubkyProfile: PubkyProfileManager - @EnvironmentObject var contactsManager: ContactsManager - @Environment(\.scenePhase) var scenePhase - - @State private var isAuthenticating = false - @State private var isWaitingForRing = false - @State private var isLoadingAfterAuth = false - @State private var isRingInstalled = false - @State private var showRingNotInstalledDialog = false - - private let pubkyRingAppStoreUrl = "https://apps.apple.com/app/pubky-ring/id6739356756" - - var body: some View { - ZStack { - GeometryReader { geo in - Image("tag-pubky") - .resizable() - .scaledToFit() - .frame(width: geo.size.width * 0.83) - .position( - x: geo.size.width * 0.321, - y: geo.size.height * 0.376 - ) - - Image("keyring") - .resizable() - .scaledToFit() - .frame(width: geo.size.width * 0.83) - .opacity(0.9) - .position( - x: geo.size.width * 0.841, - y: geo.size.height * 0.305 - ) - } - .ignoresSafeArea() - - VStack(spacing: 0) { - NavigationBar(title: t("profile__nav_title")) - .padding(.horizontal, 16) - - Spacer() - - VStack(alignment: .leading, spacing: 0) { - Image("pubky-ring-logo") - .resizable() - .scaledToFit() - .frame(height: 36) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.bottom, 24) - - VStack(alignment: .leading, spacing: 8) { - DisplayText( - t("profile__ring_auth_title"), - accentColor: .pubkyGreen - ) - .frame(maxWidth: .infinity, alignment: .leading) - .fixedSize(horizontal: false, vertical: true) - - BodyMText(isLoadingAfterAuth - ? t("profile__ring_loading") - : isWaitingForRing ? t("profile__ring_waiting") : t("profile__ring_auth_description")) - .frame(maxWidth: .infinity, alignment: .leading) - .fixedSize(horizontal: false, vertical: true) - } - - Spacer() - .frame(height: 24) - - if isRingInstalled { - if isWaitingForRing || isLoadingAfterAuth { - VStack(spacing: 12) { - CustomButton( - title: t(isLoadingAfterAuth ? "profile__ring_loading" : "profile__ring_waiting"), - isLoading: true - ) {} - .disabled(true) - - if !isLoadingAfterAuth { - Button { - isWaitingForRing = false - Task { await pubkyProfile.cancelAuthentication() } - } label: { - Text(t("common__cancel")) - .font(Fonts.semiBold(size: 15)) - .foregroundColor(.white64) - } - .accessibilityIdentifier("PubkyRingCancelAuth") - } - } - } else { - CustomButton( - title: t("profile__ring_authorize"), - isLoading: isAuthenticating - ) { - await authenticate() - } - .accessibilityIdentifier("PubkyRingAuthorize") - } - } else { - CustomButton(title: t("profile__ring_download")) { - if let url = URL(string: pubkyRingAppStoreUrl) { - await UIApplication.shared.open(url) - } - } - .accessibilityIdentifier("PubkyRingDownload") - } - } - .padding(.horizontal, 16) - } - } - .clipped() - .frame(maxWidth: .infinity, maxHeight: .infinity) - .bottomSafeAreaPadding() - .background(Color.customBlack) - .navigationBarHidden(true) - .task { - checkRingInstalled() - } - .task(id: isWaitingForRing) { - guard isWaitingForRing else { return } - await waitForApproval() - } - .onChange(of: scenePhase) { _, newPhase in - if newPhase == .active { - checkRingInstalled() - } - } - .onChange(of: pubkyProfile.authState) { _, authState in - authState.resetRingAuthViewStateIfNeeded( - isAuthenticating: $isAuthenticating, - isWaitingForRing: $isWaitingForRing, - isLoadingAfterAuth: $isLoadingAfterAuth - ) - } - .alert(t("profile__ring_not_installed_title"), isPresented: $showRingNotInstalledDialog) { - Button(t("profile__ring_download")) { - if let url = URL(string: pubkyRingAppStoreUrl) { - Task { await UIApplication.shared.open(url) } - } - } - Button(t("common__dialog_cancel"), role: .cancel) {} - } message: { - Text(t("profile__ring_not_installed_description")) - } - } - - private func checkRingInstalled() { - isRingInstalled = PubkyProfileManager.isRingAvailable() - } - - private func authenticate() async { - if isWaitingForRing { - isWaitingForRing = false - await pubkyProfile.cancelAuthentication() - } - - isAuthenticating = true - - do { - try await pubkyProfile.startAuthentication() - isAuthenticating = false - isWaitingForRing = true - } catch PubkyServiceError.ringNotInstalled { - isAuthenticating = false - isRingInstalled = false - showRingNotInstalledDialog = true - } catch { - isAuthenticating = false - app.toast(type: .error, title: t("profile__auth_error_title"), description: error.localizedDescription) - } - } - - private func waitForApproval() async { - do { - let publicKey = try await pubkyProfile.completeAuthentication() - isLoadingAfterAuth = true - await navigateAfterAuth(publicKey: publicKey) - } catch is CancellationError { - return - } catch { - isWaitingForRing = false - app.toast(type: .error, title: t("profile__auth_error_title"), description: error.localizedDescription) - } - } - - private func navigateAfterAuth(publicKey: String) async { - let destination = await contactsManager.destinationAfterAuthentication( - profile: pubkyProfile.profile, - publicKey: publicKey - ) - navigation.path = [destination] - pubkyProfile.finalizeAuthentication() - } -} - -#Preview { - NavigationStack { - PubkyRingAuthView() - .environmentObject(AppViewModel()) - .environmentObject(NavigationViewModel()) - .environmentObject(PubkyProfileManager()) - .environmentObject(ContactsManager()) - } - .preferredColorScheme(.dark) -} diff --git a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift index a91807222..19f3d94b7 100644 --- a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift +++ b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift @@ -443,13 +443,7 @@ struct PubkyAuthApprovalSheet: View { return } - guard let secretKey = try Keychain.loadString(key: .pubkySecretKey), - !secretKey.isEmpty - else { - app.toast(type: .error, title: t("pubky_auth__no_identity")) - state = .authorize - return - } + let secretKey = try pubkyProfile.activeIdentitySecretKey() try await PubkyService.approveAuthRequest( request: config.request, diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index 27cc195d1..c10c668aa 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -22,6 +22,24 @@ final class ContactsManagerTests: XCTestCase { XCTAssertEqual(PubkyPublicKeyFormat.normalized(prefixedKey), prefixedKey) } + func testPubkyPublicKeyFormatPreservesBareKeyBeginningWithPrefixText() { + let rawKey = "pubky\(String(repeating: "y", count: 47))" + let prefixedKey = "pubky\(rawKey)" + + XCTAssertEqual(PubkyPublicKeyFormat.normalized(rawKey), prefixedKey) + XCTAssertEqual(PubkyPublicKeyFormat.normalized(prefixedKey), prefixedKey) + XCTAssertEqual(PubkyPublicKeyFormat.displayTruncated(rawKey), "pubk...yyyy") + } + + func testPubkyPublicKeyFormatStripsPrefixFromNonCanonicalDisplayKeys() { + // Legacy and truncated keys are not bare keys, so the prefix remains display noise. + XCTAssertEqual( + PubkyPublicKeyFormat.displayTruncated("pubkyz6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"), + "z6Mk...2doK" + ) + XCTAssertEqual(PubkyPublicKeyFormat.displayTruncated("pubkyz6MkhaXgBZDvotDk"), "z6Mk...otDk") + } + func testPubkyPublicKeyFormatRejectsInvalidLengthAndCharacters() { XCTAssertNil(PubkyPublicKeyFormat.normalized("pubkyshort")) XCTAssertNil(PubkyPublicKeyFormat.normalized("pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5x0")) diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index e80a275d5..df95047c4 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -96,6 +96,70 @@ final class PaykitPaymentProofServiceTests: XCTestCase { XCTAssertEqual(processCallCount, 1) } + func testReconcilePreservesCompletedProofWhenBorrowedIdentityIsRevokedBeforeSubmission() async throws { + let record = try paymentRequestRecord() + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let proof = PendingPaykitPaymentProof( + identity: identity, + requestId: request.id, + paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + kind: .lightning, + paymentStarted: true, + paymentIdentifier: paymentHash, + proofData: preimage + ) + let store = PaymentProofMemoryStore() + await store.seed([proof]) + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let source = PaymentProofSourceValidationMock() + await source.revoke(afterSuccessfulValidations: 1) + let service = paymentProofService( + sdk: sdk, + store: store, + revalidateSourceBeforeWrite: { try await source.validate() } + ) + + await service.reconcile() + + let remainingProofs = await store.snapshot() + let submissionCount = await sdk.submissionCount() + let processCallCount = await sdk.processCallCount() + let identityStatusCallCount = await sdk.identityStatusCallCount() + XCTAssertEqual(remainingProofs, [proof]) + XCTAssertEqual(submissionCount, 0) + XCTAssertEqual(processCallCount, 0) + XCTAssertEqual(identityStatusCallCount, 1) + } + + func testProofSubmissionPreservesCompletedProofWhenBorrowedIdentityWasRevoked() async throws { + let record = try paymentRequestRecord() + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let source = PaymentProofSourceValidationMock() + let service = paymentProofService( + sdk: sdk, + store: store, + revalidateSourceBeforeWrite: { try await source.validate() } + ) + try await service.prepare( + request: request, + paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + kind: .lightning + ) + try await service.associateLightningPayment(request, paymentHash: paymentHash) + await source.revoke() + + await service.completeLightningPayment(paymentHash: paymentHash, preimage: preimage) + + let persistedProof = await store.snapshot().first + let submissionCount = await sdk.submissionCount() + let processCallCount = await sdk.processCallCount() + XCTAssertEqual(persistedProof?.proofData, preimage) + XCTAssertEqual(submissionCount, 0) + XCTAssertEqual(processCallCount, 0) + } + func testMismatchedLightningPreimageIsNotSubmitted() async throws { let record = try paymentRequestRecord() let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) @@ -848,7 +912,8 @@ final class PaykitPaymentProofServiceTests: XCTestCase { lightningStatus: PaykitLightningPaymentProofStatus = .unknown, onchainTxids: [String] = [], existingOnchainTxids: Set = [], - onchainLookupFails: Bool = false + onchainLookupFails: Bool = false, + revalidateSourceBeforeWrite: @escaping @Sendable () async throws -> Void = {} ) -> PaykitPaymentProofService { PaykitPaymentProofService( sdk: sdk, @@ -859,6 +924,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { existingTransactionIds: existingOnchainTxids, transactionLookupFails: onchainLookupFails ), + revalidateSourceBeforeWrite: revalidateSourceBeforeWrite, logInfo: { _ in }, logWarning: { _ in } ) @@ -977,6 +1043,22 @@ private actor PaymentProofMemoryStore: PaykitPaymentProofStoring { } } +private actor PaymentProofSourceValidationMock { + private var successfulValidationsBeforeRevocation: Int? + + func revoke(afterSuccessfulValidations: Int = 0) { + successfulValidationsBeforeRevocation = afterSuccessfulValidations + } + + func validate() throws { + guard let successfulValidationsBeforeRevocation else { return } + guard successfulValidationsBeforeRevocation > 0 else { + throw SharedPubkyIdentityError.sourceIdentityMissing + } + self.successfulValidationsBeforeRevocation = successfulValidationsBeforeRevocation - 1 + } +} + private struct PaymentProofLightningLookup: PaykitLightningPaymentProofLookingUp { let status: PaykitLightningPaymentProofStatus diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index ac09cd9c3..0983d6a76 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -2580,6 +2580,102 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(snapshot.processCallCount, 2) } + func testLifecycleWritesRevalidateBorrowedIdentityBeforeSdkMutation() async throws { + let record = try paymentRequestRecord() + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let sdk = PaymentRequestSdkMock(records: [record]) + let service = PaykitPaymentRequestService( + sdk: sdk, + revalidateSourceBeforeWrite: { + throw SharedPubkyIdentityError.sourceIdentityMissing + }, + logWarning: { _ in } + ) + let actions: [() async throws -> Void] = [ + { try await service.accept(request) }, + { try await service.reject(request) }, + { try await service.cancel(request) }, + ] + + for action in actions { + do { + try await action() + XCTFail("Expected revoked shared identity to stop the lifecycle write") + } catch { + XCTAssertEqual(error as? SharedPubkyIdentityError, .sourceIdentityMissing) + } + } + + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.lifecycleMutationCallCount, 0) + XCTAssertEqual(snapshot.processCallCount, 0) + } + + func testProposalWritesRevalidateBorrowedIdentityBeforeSdkMutation() async throws { + let optionKey = PublicPaykitService.lightningPaymentOptionEnabledKey + let previousOption = UserDefaults.standard.object(forKey: optionKey) + defer { UserDefaults.standard.set(previousOption, forKey: optionKey) } + UserDefaults.standard.set(true, forKey: optionKey) + + let identity = "pubky\(String(repeating: "z", count: 52))" + let counterparty = "pubky\(String(repeating: "y", count: 52))" + let target = PaykitPaymentRequestTarget(publicKey: counterparty, receiverPath: PaykitReceiverPath.wallet) + let expiration = Date().addingTimeInterval(60) + let sdk = PaymentRequestSdkMock(records: []) + await sdk.configureRecipients( + peers: [linkedPeer(counterparty: counterparty, path: target.receiverPath, state: .linked)], + receiverPathsByPublicKey: [counterparty: [target.receiverPath]] + ) + let service = PaykitPaymentRequestService( + sdk: sdk, + isPrivatePaymentPublishingEnabled: { true }, + revalidateSourceBeforeWrite: { + throw SharedPubkyIdentityError.sourceIdentityMissing + }, + logWarning: { _ in } + ) + + do { + _ = try await service.propose( + PaykitPaymentRequestDraft(amountSats: 1, note: "", expiresAt: expiration), + to: target, + savedPublicKeys: [counterparty], + expectedIdentity: identity + ) + XCTFail("Expected revoked shared identity to stop the proposal") + } catch { + XCTAssertEqual(error as? SharedPubkyIdentityError, .sourceIdentityMissing) + } + + let image = UIGraphicsImageRenderer(size: CGSize(width: 4, height: 4)).image { context in + UIColor.purple.setFill() + context.fill(CGRect(x: 0, y: 0, width: 4, height: 4)) + } + do { + _ = try await service.proposeSubscription( + PaykitSubscriptionDraft( + amountSats: 1, + name: "Support", + description: "", + frequency: .month, + expiresAt: expiration, + iconData: XCTUnwrap(image.pngData()) + ), + to: target, + savedPublicKeys: [counterparty], + expectedIdentity: identity, + validateBeforeProposing: {} + ) + XCTFail("Expected revoked shared identity to stop the subscription icon upload") + } catch { + XCTAssertEqual(error as? SharedPubkyIdentityError, .sourceIdentityMissing) + } + + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.uploadCount, 0) + XCTAssertTrue(snapshot.proposedRequests.isEmpty) + } + func testQueuedAcceptanceSucceedsWhenImmediateDeliveryIsCancelled() async throws { let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) let manager = paymentRequestManager(sdk: sdk) @@ -3292,6 +3388,7 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling { private var isProcessPaused = false private var processContinuation: CheckedContinuation? private var receiveError: PaymentRequestSdkMockError? + private var lifecycleMutationCallCount = 0 private var acceptedRequests: [PaymentRequestInvocation] = [] private var rejectedRequests: [PaymentRequestInvocation] = [] private var acceptFailuresAfterRemoval = 0 @@ -3435,6 +3532,7 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling { counterpartyReceiverPath: String, paymentRequestId: String ) async throws -> PaymentRequestRecord { + lifecycleMutationCallCount += 1 if shouldPauseNextAccept { shouldPauseNextAccept = false isAcceptPaused = true @@ -3476,6 +3574,7 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling { paymentRequestId: String, reason _: String? ) throws -> PaymentRequestRecord { + lifecycleMutationCallCount += 1 let record = try removeRecord( counterparty: counterparty, counterpartyReceiverPath: counterpartyReceiverPath, @@ -3499,7 +3598,8 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling { paymentRequestId: String, reason _: String? ) throws -> PaymentRequestRecord { - try removeRecord( + lifecycleMutationCallCount += 1 + return try removeRecord( counterparty: counterparty, counterpartyReceiverPath: counterpartyReceiverPath, id: paymentRequestId @@ -3624,6 +3724,7 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling { uploadCount: uploadCount, processCallCount: processCallCount, receiveCallCount: receiveCallCount, + lifecycleMutationCallCount: lifecycleMutationCallCount, acceptedRequests: acceptedRequests, rejectedRequests: rejectedRequests, proposedRequests: proposedRequests @@ -3650,6 +3751,7 @@ private struct PaymentRequestSdkSnapshot { let uploadCount: Int let processCallCount: Int let receiveCallCount: Int + let lifecycleMutationCallCount: Int let acceptedRequests: [PaymentRequestInvocation] let rejectedRequests: [PaymentRequestInvocation] let proposedRequests: [ProposedPaymentRequestInvocation] diff --git a/BitkitTests/PaykitSdkClientConfigTests.swift b/BitkitTests/PaykitSdkClientConfigTests.swift index 3c461ba70..4a3f5b978 100644 --- a/BitkitTests/PaykitSdkClientConfigTests.swift +++ b/BitkitTests/PaykitSdkClientConfigTests.swift @@ -90,78 +90,4 @@ final class PaykitSdkClientConfigTests: XCTestCase { [KeychainEntryType.paykitSession.storageKey, KeychainEntryType.pubkySecretKey.storageKey] ) } - - func testFailedAuthActivationDiscardsOnlyItsPersistedSession() async { - for shouldPersist in [false, true] { - for activationError in [PubkyServiceError.sessionNotActive as Error, CancellationError()] { - var storedSession = "previous-session" - var revoked = false - do { - _ = try await PaykitSdkService.completeAuthActivation( - sessionSecret: "new-session", - activate: { - if shouldPersist { storedSession = "new-session" } - throw activationError - }, - discardSessionAccess: { session in - _ = await PaykitSdkService.discardAuthSession( - sessionSecret: session, - storedSessionSecret: { storedSession }, - revoke: { revoked = true }, - forget: { XCTFail("Revocation succeeded") } - ) - } - ) - XCTFail("Expected activation to fail") - } catch { - XCTAssertEqual(error is CancellationError, activationError is CancellationError) - XCTAssertEqual(revoked, shouldPersist) - } - } - } - } - - func testLateAuthCleanupPreservesNewerSession() async { - let matched = await PaykitSdkService.discardAuthSession( - sessionSecret: "canceled-session", - storedSessionSecret: { "newer-session" }, - revoke: { XCTFail("Must not revoke the newer session") }, - forget: { XCTFail("Must not forget the newer session") } - ) - - XCTAssertFalse(matched) - } - - func testAuthCleanupForgetsMatchingSessionWhenRevocationFails() async { - var didForget = false - let matched = await PaykitSdkService.discardAuthSession( - sessionSecret: "canceled-session", - storedSessionSecret: { "canceled-session" }, - revoke: { throw PubkyServiceError.sessionNotActive }, - forget: { didForget = true } - ) - - XCTAssertTrue(matched) - XCTAssertTrue(didForget) - } - - func testFailedCleanupPreservesOriginalActivationError() async { - do { - _ = try await PaykitSdkService.completeAuthActivation( - sessionSecret: "new-session", - activate: { throw CancellationError() }, - discardSessionAccess: { session in - _ = await PaykitSdkService.discardAuthSession( - sessionSecret: session, - storedSessionSecret: { "new-session" }, - revoke: { throw PubkyServiceError.sessionNotActive }, - forget: { throw PubkyServiceError.sessionNotActive } - ) - } - ) - XCTFail("Expected activation cancellation") - } catch { - XCTAssertTrue(error is CancellationError) - } - } } diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index dcb92cf69..c346fca42 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -272,6 +272,31 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertEqual(decoded.paykitSdkBackupState, backup.paykitSdkBackupState) } + func testPrivatePaykitBackupExcludesBorrowedIdentityState() throws { + let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + let reference = try SharedPubkyIdentityRefV1(sourceApp: .ring, pubky: publicKey) + + XCTAssertFalse(try PrivatePaykitService.shouldExportBackupState( + currentPublicKey: publicKey, + loadSharedIdentityReference: { reference } + )) + XCTAssertTrue(try PrivatePaykitService.shouldExportBackupState( + currentPublicKey: publicKey, + loadSharedIdentityReference: { nil } + )) + XCTAssertFalse(try PrivatePaykitService.shouldExportBackupState( + currentPublicKey: nil, + loadSharedIdentityReference: { + XCTFail("No session should skip shared identity storage") + return reference + } + )) + XCTAssertThrowsError(try PrivatePaykitService.shouldExportBackupState( + currentPublicKey: publicKey, + loadSharedIdentityReference: { throw SharedPubkyIdentityError.invalidRecord } + )) + } + func testReservationStoreBacksUpRestoredCeiling() async throws { let suiteName = "PrivatePaykitServiceTests.\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) @@ -390,6 +415,34 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertFalse(contactState?.hasContactOwnedCacheState == true) } + func testCloseAndClearRemovesConsumedPrivatePaymentListVersions() async { + let defaults = UserDefaults.standard + let previousState = defaults.data(forKey: PrivatePaykitService.cacheStateKey) + defer { + if let previousState { + defaults.set(previousState, forKey: PrivatePaykitService.cacheStateKey) + } else { + defaults.removeObject(forKey: PrivatePaykitService.cacheStateKey) + } + } + + let service = PrivatePaykitService() + let publicKey = "pubkycontact" + var contactState = PrivatePaykitService.ContactState() + contactState.consumedPrivatePaymentListVersionsByReceiverPath[PaykitReceiverPath.server] = 9 + await service.setTestContactState(contactState, publicKey: publicKey) + + await service.closeAndClear() + + let clearedState = await service.testContactState(publicKey: publicKey) + XCTAssertNil(clearedState) + let persistedData = defaults.data(forKey: PrivatePaykitService.cacheStateKey) + let persistedState = persistedData.flatMap { + try? JSONDecoder().decode(PrivatePaykitService.PrivatePaykitState.self, from: $0) + } + XCTAssertTrue(persistedState?.contacts.isEmpty == true) + } + func testPrivatePaymentRecoveryUsesRequestedReceiverPath() async { let service = PrivatePaykitService() let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" diff --git a/BitkitTests/PubkyAuthURLSchemeTests.swift b/BitkitTests/PubkyAuthURLSchemeTests.swift index 5dc30213f..d548827ae 100644 --- a/BitkitTests/PubkyAuthURLSchemeTests.swift +++ b/BitkitTests/PubkyAuthURLSchemeTests.swift @@ -100,6 +100,29 @@ final class PubkyAuthURLSchemeTests: XCTestCase { XCTAssertNil(sheets.activeSheetConfiguration) } + @MainActor + func testLegacyRingCallbacksWaitForStartupThenRouteOnceWithoutLightning() async throws { + for path in ["success", "cancel", "error"] { + for query in ["", "?nonce=stale-attempt", "?nonce", "?errorMessage=Denied&nonce=stale-attempt"] { + let app = AppViewModel(sheetViewModel: SheetViewModel(), navigationViewModel: NavigationViewModel()) + let url = try XCTUnwrap(URL(string: "bitkit://pubky-auth/\(path)\(query)")) + var routedURLs: [URL] = [] + + app.retainDeepLink(url) + await app.routePendingDeepLinkIfReady(false, nodeIsRunning: false) { routedURLs.append($0) } + XCTAssertEqual(app.pendingDeepLinkURL, url) + XCTAssertTrue(routedURLs.isEmpty) + + await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { routedURLs.append($0) } + XCTAssertEqual(routedURLs, [url]) + XCTAssertNil(app.pendingDeepLinkURL) + + await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { routedURLs.append($0) } + XCTAssertEqual(routedURLs, [url]) + } + } + } + @MainActor func testNonNodeDeepLinksReleaseAfterStartupGatesWithoutWaitingForLDK() async throws { let app = AppViewModel(sheetViewModel: SheetViewModel(), navigationViewModel: NavigationViewModel()) diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index 308c170fc..7f2a94709 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -4,6 +4,119 @@ import struct Paykit.PubkySessionBootstrapResult import XCTest final class PubkyProfileManagerTests: XCTestCase { + @MainActor + func testForegroundMaintenanceWaitsForSharedIdentityValidation() async { + let validationStarted = expectation(description: "Shared identity validation started") + let validation = AsyncStream.makeStream() + var walletPaykitPermission: Bool? + var didRunPaykitMaintenance = false + + let task = Task { @MainActor in + await AppScene.performForegroundMaintenance( + waitForSharedIdentityValidation: { + validationStarted.fulfill() + for await result in validation.stream { + return result + } + return false + }, + walletMaintenance: { walletPaykitPermission = $0 }, + paykitMaintenance: { didRunPaykitMaintenance = true } + ) + } + + await fulfillment(of: [validationStarted], timeout: 1) + await Task.yield() + XCTAssertNil(walletPaykitPermission) + XCTAssertFalse(didRunPaykitMaintenance) + + validation.continuation.yield(true) + validation.continuation.finish() + await task.value + XCTAssertEqual(walletPaykitPermission, true) + XCTAssertTrue(didRunPaykitMaintenance) + } + + @MainActor + func testForegroundMaintenancePreservesWalletSyncButStopsPaykitAfterFailedValidation() async { + var walletPaykitPermission: Bool? + var didRunPaykitMaintenance = false + + await AppScene.performForegroundMaintenance( + waitForSharedIdentityValidation: { false }, + walletMaintenance: { walletPaykitPermission = $0 }, + paykitMaintenance: { didRunPaykitMaintenance = true } + ) + + XCTAssertEqual(walletPaykitPermission, false) + XCTAssertFalse(didRunPaykitMaintenance) + } + + @MainActor + func testFailedSharedSessionRestorationDoesNotAuthorizePaykitMaintenance() { + XCTAssertTrue(PubkyProfileManager.canPerformPaykitMaintenance(afterSharedSessionRestoration: nil)) + XCTAssertTrue(PubkyProfileManager.canPerformPaykitMaintenance(afterSharedSessionRestoration: .restored(publicKey: "pubky"))) + XCTAssertFalse(PubkyProfileManager.canPerformPaykitMaintenance(afterSharedSessionRestoration: .noSession)) + XCTAssertFalse(PubkyProfileManager.canPerformPaykitMaintenance(afterSharedSessionRestoration: .restorationFailed)) + } + + func testPaykitMaintenancePermissionChangesOnlyForRealAuthenticationTransitions() { + XCTAssertNil(AppScene.paykitMaintenancePermission( + previousAuthState: .authenticated, + authState: .authenticated + )) + XCTAssertEqual(AppScene.paykitMaintenancePermission( + previousAuthState: .idle, + authState: .authenticated + ), true) + XCTAssertEqual(AppScene.paykitMaintenancePermission( + previousAuthState: .authenticated, + authState: .idle + ), false) + } + + @MainActor + func testSharedIdentityDiscoveryTransitionsHideCreationUntilSuccessfulEmptyLoad() async { + let manager = PubkyProfileManager() + let (stream, continuation) = AsyncStream<[SharedPubkyIdentityRefV1]>.makeStream() + + await manager.refreshSharedRingIdentities( + isRingAvailable: false, + loadReferences: { + XCTFail("Ring absence should complete without reading shared storage") + return [] + } + ) + XCTAssertEqual(manager.sharedRingIdentityDiscoveryState, .loaded) + + let loadingStarted = expectation(description: "Shared identity discovery started") + let refresh = Task { @MainActor in + await manager.refreshSharedRingIdentities( + isRingAvailable: true, + loadReferences: { + loadingStarted.fulfill() + for await references in stream { + return references + } + return [] + } + ) + } + await fulfillment(of: [loadingStarted], timeout: 1) + XCTAssertEqual(manager.sharedRingIdentityDiscoveryState, .loading) + + continuation.yield([]) + continuation.finish() + await refresh.value + XCTAssertEqual(manager.sharedRingIdentityDiscoveryState, .loaded) + + await manager.refreshSharedRingIdentities( + isRingAvailable: true, + loadReferences: { throw SharedPubkyIdentityError.temporarilyUnavailable } + ) + XCTAssertEqual(manager.sharedRingIdentityDiscoveryState, .unavailable) + } + @MainActor func testIdentityRestorationPreservesCredentialsForRetry() async throws { for failedStep in ["load", "signIn", "profile"] { @@ -110,6 +223,42 @@ final class PubkyProfileManagerTests: XCTestCase { } } + @MainActor + func testCreateIdentityRefusesToSignUpOverAnUnrecoverableSession() async throws { + let defaults = UserDefaults.standard + let previousPending = defaults.object(forKey: "pubky_profile_setup_pending") + let previousSession = try? Keychain.loadString(key: .paykitSession) + let previousSecretKey = try? Keychain.loadString(key: .pubkySecretKey) + addTeardownBlock { + try? Keychain.delete(key: .paykitSession) + try? Keychain.delete(key: .pubkySecretKey) + if let previousSession { + try? Keychain.saveString(key: .paykitSession, str: previousSession) + } + if let previousSecretKey { + try? Keychain.saveString(key: .pubkySecretKey, str: previousSecretKey) + } + defaults.set(previousPending, forKey: "pubky_profile_setup_pending") + } + + // An external or borrowed session with no local secret to re-sign-in with. + try Keychain.delete(key: .pubkySecretKey) + try Keychain.delete(key: .paykitSession) + try Keychain.saveString(key: .paykitSession, str: "external-session-secret") + defaults.set(false, forKey: "pubky_profile_setup_pending") + let manager = KeyDerivationProbeProfileManager() + + do { + try await manager.createIdentity(name: "Test", bio: "", links: [], loadStoredSecretKey: { nil }) + XCTFail("Expected an unrecoverable session to block identity creation") + } catch { + XCTAssertFalse(manager.didDeriveKeys, "A new identity must not be derived over an existing session") + } + + XCTAssertEqual(try Keychain.loadString(key: .paykitSession), "external-session-secret") + XCTAssertNil(try Keychain.loadString(key: .pubkySecretKey)) + } + @MainActor func testSignupFinishesProfileSetupOnlyAfterActivation() async throws { let defaults = UserDefaults.standard @@ -301,20 +450,6 @@ final class PubkyProfileManagerTests: XCTestCase { // MARK: - Ring callbacks - func testPubkyRingAuthURLBuilderAddsXCallbackParams() throws { - let url = try XCTUnwrap(PubkyRingAuthURLBuilder.addingCallbacks(to: "pubkyauth://auth?relay=https%3A%2F%2Frelay.example")) - let components = try XCTUnwrap(URLComponents(string: url)) - let queryItems = Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in - item.value.map { (item.name, $0) } - }) - - XCTAssertEqual(queryItems["relay"], "https://relay.example") - XCTAssertEqual(queryItems["x-success"], PubkyRingAuthURLBuilder.successCallback) - XCTAssertEqual(queryItems["x-cancel"], PubkyRingAuthURLBuilder.cancelCallback) - XCTAssertEqual(queryItems["x-error"], PubkyRingAuthURLBuilder.errorCallback) - XCTAssertEqual(queryItems["x-source"], PubkyRingAuthURLBuilder.source) - } - func testPubkyRingAuthCallbackParsesSuccessCancelAndError() throws { XCTAssertEqual( try PubkyRingAuthCallback.parse(url: XCTUnwrap(URL(string: "bitkit://pubky-auth/success"))), @@ -330,57 +465,6 @@ final class PubkyProfileManagerTests: XCTestCase { ) } - func testPubkyRingAuthURLBuilderAddsNonceToCallbackParams() throws { - let nonce = try XCTUnwrap(UUID(uuidString: "12345678-1234-1234-1234-123456789ABC")) - let url = try XCTUnwrap(PubkyRingAuthURLBuilder.addingCallbacks(to: "pubkyauth://auth", nonce: nonce)) - let components = try XCTUnwrap(URLComponents(string: url)) - let queryItems = Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in - item.value.map { (item.name, $0) } - }) - - XCTAssertEqual(queryItems["x-success"], "bitkit://pubky-auth/success?nonce=12345678-1234-1234-1234-123456789ABC") - XCTAssertEqual(queryItems["x-cancel"], "bitkit://pubky-auth/cancel?nonce=12345678-1234-1234-1234-123456789ABC") - XCTAssertEqual(queryItems["x-error"], "bitkit://pubky-auth/error?nonce=12345678-1234-1234-1234-123456789ABC") - } - - func testPubkyRingAuthURLBuilderCreatesRingSpecificHandoff() throws { - let authUrl = "pubkyauth://signin?caps=/pub/bitkit.to/:rw&relay=https%3A%2F%2Frelay.example&secret=test" - let callbackAuthUrl = try XCTUnwrap(PubkyRingAuthURLBuilder.addingCallbacks(to: authUrl)) - let ringUrl = try XCTUnwrap(PubkyRingAuthURLBuilder.ringHandoffURL(from: callbackAuthUrl)) - let components = try XCTUnwrap(URLComponents(url: ringUrl, resolvingAgainstBaseURL: false)) - let queryItems = Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in - item.value.map { (item.name, $0) } - }) - - XCTAssertEqual(components.scheme, "pubkyring") - XCTAssertEqual(components.host, "signin") - XCTAssertEqual(components.path, "") - XCTAssertEqual(queryItems["caps"], "/pub/bitkit.to/:rw") - XCTAssertEqual(queryItems["relay"], "https://relay.example") - XCTAssertEqual(queryItems["secret"], "test") - XCTAssertEqual(queryItems["x-success"], PubkyRingAuthURLBuilder.successCallback) - XCTAssertEqual(queryItems["x-cancel"], PubkyRingAuthURLBuilder.cancelCallback) - XCTAssertEqual(queryItems["x-error"], PubkyRingAuthURLBuilder.errorCallback) - XCTAssertEqual(queryItems["x-source"], PubkyRingAuthURLBuilder.source) - } - - func testPubkyRingAuthURLBuilderCreatesRingSpecificHandoffFromLegacyRootURL() throws { - let ringUrl = try XCTUnwrap( - PubkyRingAuthURLBuilder.ringHandoffURL( - from: "pubkyauth:///?caps=/pub/bitkit.to/:rw&relay=https%3A%2F%2Frelay.example&secret=test" - ) - ) - let components = try XCTUnwrap(URLComponents(url: ringUrl, resolvingAgainstBaseURL: false)) - - XCTAssertEqual(components.scheme, "pubkyring") - XCTAssertEqual(components.host, "signin") - XCTAssertEqual(components.path, "") - } - - func testPubkyRingAuthURLBuilderRejectsOtherSchemes() { - XCTAssertNil(PubkyRingAuthURLBuilder.ringHandoffURL(from: "bitkit://pubky-auth/success")) - } - func testPubkyRingAuthCallbackParsesNonce() throws { XCTAssertEqual( try PubkyRingAuthCallback.parse(url: XCTUnwrap(URL(string: "bitkit://pubky-auth/error?nonce=abc&errorMessage=Denied"))), @@ -398,36 +482,41 @@ final class PubkyProfileManagerTests: XCTestCase { func testPubkyRingAuthCallbackRejectsOtherDeeplinks() throws { XCTAssertNil(try PubkyRingAuthCallback.parse(url: XCTUnwrap(URL(string: "bitkit://wallet/success")))) XCTAssertNil(try PubkyRingAuthCallback.parse(url: XCTUnwrap(URL(string: "https://pubky-auth/success")))) + XCTAssertNil(try PubkyRingAuthCallback.parse(url: XCTUnwrap(URL(string: "bitkit://pubky-auth/setup")))) + XCTAssertNil(try PubkyRingAuthCallback.parse(url: XCTUnwrap(URL(string: "bitkit://pubky-auth/unknown")))) } @MainActor - func testNonceMismatchedCancelCallbackDoesNotAbortActiveAuthAttempt() async { - let manager = PubkyProfileManager() - let attemptID = UUID() - - manager.setActiveAuthAttemptIDForTesting(attemptID) - manager.authState = .authenticating - - let result = await manager.handleAuthCallback(.cancel(nonce: UUID().uuidString)) - - XCTAssertEqual(result, .ignored) - XCTAssertEqual(manager.activeAuthAttemptIDForTesting, attemptID) - XCTAssertEqual(manager.authState, .authenticating) - } - - @MainActor - func testNonceMismatchedErrorCallbackDoesNotAbortActiveAuthAttempt() async { - let manager = PubkyProfileManager() - let attemptID = UUID() - - manager.setActiveAuthAttemptIDForTesting(attemptID) - manager.authState = .authenticating - - let result = await manager.handleAuthCallback(.error(message: "Denied", nonce: UUID().uuidString)) - - XCTAssertEqual(result, .ignored) - XCTAssertEqual(manager.activeAuthAttemptIDForTesting, attemptID) - XCTAssertEqual(manager.authState, .authenticating) + func testLegacyRingCallbacksPreserveCurrentIdentityAndAuthenticationState() { + let callbacks: [PubkyRingAuthCallback] = [ + .success(nonce: nil), + .success(nonce: UUID().uuidString), + .cancel(nonce: nil), + .cancel(nonce: UUID().uuidString), + .error(message: "Denied", nonce: nil), + .error(message: "Untrusted callback message", nonce: UUID().uuidString), + ] + let states: [PubkyAuthState] = [ + .idle, .authenticating, .completingAuthentication, .authenticated, .error("Existing error"), + ] + let publicKeys: [String?] = [nil, "pubky_test"] + + for publicKey in publicKeys { + for state in states { + let manager = PubkyProfileManager() + manager.publicKey = publicKey + manager.authState = state + manager.profile = publicKey.map { PubkyProfile.placeholder(publicKey: $0) } + + for callback in callbacks { + manager.handleAuthCallback(callback) + + XCTAssertEqual(manager.publicKey, publicKey) + XCTAssertEqual(manager.profile?.publicKey, publicKey) + XCTAssertEqual(manager.authState, state) + } + } + } } @MainActor @@ -483,84 +572,6 @@ final class PubkyProfileManagerTests: XCTestCase { } } - @MainActor - func testCompleteAuthenticationRevokesSessionWhenAuthIsCanceledAfterCompletion() async { - let manager = PubkyProfileManager() - let attemptID = UUID() - var didDiscardSession = false - - manager.setActiveAuthAttemptIDForTesting(attemptID) - manager.authState = .authenticating - - do { - try await manager.completeAuthenticationForTesting( - completeAuth: { - manager.setActiveAuthAttemptIDForTesting(nil) - return "new-session" - }, - currentPublicKey: { - "pubky_test" - }, - discardSessionAccess: { sessionSecret in - XCTAssertEqual(sessionSecret, "new-session") - didDiscardSession = true - } - ) - XCTFail("Expected cancellation") - } catch is CancellationError { - XCTAssertTrue(didDiscardSession) - XCTAssertNil(manager.activeAuthAttemptIDForTesting) - } catch { - XCTFail("Expected CancellationError, got \(error)") - } - } - - @MainActor - func testCompleteAuthenticationPreservesSessionWhenRelayFails() 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: { _ in didDiscardSession = true } - ) - XCTFail("Expected authentication activation to fail") - } catch { - XCTAssertFalse(didDiscardSession) - } - } - } - - @MainActor - func testSupersededAuthenticationPreservesNewAttempt() async { - let manager = PubkyProfileManager() - manager.setActiveAuthAttemptIDForTesting(UUID()) - manager.authState = .authenticating - let newAttemptID = UUID() - - do { - try await manager.completeAuthenticationForTesting( - completeAuth: { - manager.setActiveAuthAttemptIDForTesting(newAttemptID) - throw CancellationError() - }, - currentPublicKey: { nil }, - discardSessionAccess: { _ in XCTFail("No session was activated") } - ) - XCTFail("Expected cancellation") - } catch { - XCTAssertEqual(manager.activeAuthAttemptIDForTesting, newAttemptID) - XCTAssertEqual(manager.authState, .authenticating) - } - } - @MainActor func testDiscardAbandonedSessionForgetsLocalAccessWhenRevocationFails() async { let manager = PubkyProfileManager() @@ -873,6 +884,7 @@ final class PubkyProfileManagerTests: XCTestCase { deleteKeychainValue: { key in store.removeValue(forKey: key.storageKey) }, + deleteBitkitSharedIdentities: {}, forgetSessionAccess: { didClearSessionAccess = true }, @@ -898,6 +910,7 @@ final class PubkyProfileManagerTests: XCTestCase { paykitSession: "stale-session", pubkySecretKey: "local-secret" ) + var events: [String] = [] try await PubkyProfileManager.restoreSessionBackupState( nil, @@ -909,8 +922,16 @@ final class PubkyProfileManagerTests: XCTestCase { }, deleteKeychainValue: { key in store.removeValue(forKey: key.storageKey) + if case .pubkySecretKey = key { + events.append("private") + } + }, + deleteBitkitSharedIdentities: { + events.append("shared") + }, + forgetSessionAccess: { + events.append("session") }, - forgetSessionAccess: {}, signInWithSecretKey: { _ in XCTFail("Missing pubky state should not sign in") return "unused-session" @@ -923,6 +944,7 @@ final class PubkyProfileManagerTests: XCTestCase { XCTAssertNil(store[KeychainEntryType.paykitSession.storageKey]) XCTAssertNil(store[KeychainEntryType.pubkySecretKey.storageKey]) + XCTAssertEqual(events, ["shared", "session", "private"]) } func testRestoreSessionBackupStateReplacesSessionWhenForgetFails() async throws { @@ -936,6 +958,7 @@ final class PubkyProfileManagerTests: XCTestCase { loadKeychainString: { store[$0.storageKey] }, persistKeychainString: { store[$0.storageKey] = $1 }, deleteKeychainValue: { store.removeValue(forKey: $0.storageKey) }, + deleteBitkitSharedIdentities: {}, forgetSessionAccess: { throw PubkyServiceError.authFailed("offline") }, signInWithSecretKey: { _ in XCTFail("External session restore should not sign in with a local secret") @@ -963,6 +986,7 @@ final class PubkyProfileManagerTests: XCTestCase { loadKeychainString: { store[$0.storageKey] }, persistKeychainString: { store[$0.storageKey] = $1 }, deleteKeychainValue: { store.removeValue(forKey: $0.storageKey) }, + deleteBitkitSharedIdentities: {}, forgetSessionAccess: { throw PubkyServiceError.authFailed("offline") }, signInWithSecretKey: { _ in XCTFail("Missing pubky state should not sign in") @@ -978,6 +1002,42 @@ final class PubkyProfileManagerTests: XCTestCase { XCTAssertNil(store[KeychainEntryType.pubkySecretKey.storageKey]) } + func testRestorePreservesPrivateIdentityWhenSharedMirrorDeletionFails() async { + var store = makeKeychainStore( + paykitSession: "stale-session", + pubkySecretKey: "local-secret" + ) + var didClearSessionAccess = false + + do { + try await PubkyProfileManager.restoreSessionBackupState( + nil, + loadKeychainString: { key in + store[key.storageKey] + }, + persistKeychainString: { key, value in + store[key.storageKey] = value + }, + deleteKeychainValue: { key in + store.removeValue(forKey: key.storageKey) + }, + deleteBitkitSharedIdentities: { + throw SharedPubkyIdentityError.unavailable + }, + forgetSessionAccess: { + didClearSessionAccess = true + } + ) + XCTFail("Expected shared mirror deletion failure") + } catch { + XCTAssertEqual(error as? SharedPubkyIdentityError, .unavailable) + } + + XCTAssertFalse(didClearSessionAccess) + XCTAssertEqual(store[KeychainEntryType.paykitSession.storageKey], "stale-session") + XCTAssertEqual(store[KeychainEntryType.pubkySecretKey.storageKey], "local-secret") + } + func testRestoreSessionBackupStateForLocalSeedDerivesSecretAndClearsSession() async throws { var store = makeKeychainStore( mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", diff --git a/BitkitTests/SharedPubkyIdentityTests.swift b/BitkitTests/SharedPubkyIdentityTests.swift new file mode 100644 index 000000000..b0c11a279 --- /dev/null +++ b/BitkitTests/SharedPubkyIdentityTests.swift @@ -0,0 +1,709 @@ +@testable import Bitkit +import XCTest + +private actor SharedPubkyTestEventLog { + private var events: [String] = [] + + func append(_ event: String) { + events.append(event) + } + + func values() -> [String] { + events + } +} + +final class SharedPubkyIdentityTests: XCTestCase { + private let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + + func testFixtureMatchesRingSecretAndPubkyWireFormats() throws { + let (_, bare, secret) = try identityFixture() + + XCTAssertEqual(secret.count, 64) + XCTAssertNotNil(secret.range(of: "^[0-9a-f]{64}$", options: .regularExpression)) + XCTAssertEqual(bare.count, 52) + XCTAssertEqual(SharedPubkyKeyFormat.normalizedBare(bare), bare) + } + + func testWireKeyBeginningWithPubkyRemainsBare() { + let bare = "pubky\(String(repeating: "y", count: 47))" + + XCTAssertEqual(SharedPubkyKeyFormat.normalizedBare(bare), bare) + XCTAssertEqual(SharedPubkyKeyFormat.normalizedBare("pubky\(bare)"), bare) + } + + func testReferenceCanonicalizesPrefixedPubkyToBareWireFormat() throws { + let (prefixed, bare, _) = try identityFixture() + + let reference = try SharedPubkyIdentityRefV1(sourceApp: .ring, pubky: prefixed) + + XCTAssertEqual(reference.pubky, bare) + XCTAssertFalse(reference.pubky.hasPrefix("pubky")) + XCTAssertEqual(reference.pubky.count, 52) + XCTAssertEqual( + SharedPubkyIdentityVault.account(source: .ring, pubky: reference.pubky), + "app.pubkyring:\(bare)" + ) + } + + func testBorrowedIdentityNeverPublishesAReceiverMarker() throws { + let (prefixed, _, _) = try identityFixture() + let reference = try SharedPubkyIdentityRefV1(sourceApp: .ring, pubky: prefixed) + + XCTAssertTrue(PaykitSdkService.shouldPublishReceiverMarker(loadSharedIdentityReference: { nil })) + XCTAssertFalse(PaykitSdkService.shouldPublishReceiverMarker(loadSharedIdentityReference: { reference })) + XCTAssertFalse(PaykitSdkService.shouldPublishReceiverMarker(loadSharedIdentityReference: { + throw SharedPubkyIdentityError.invalidRecord + })) + } + + func testSharedWireFormatRejectsOverlongPubky() throws { + let (_, bare, _) = try identityFixture() + + XCTAssertNil(SharedPubkyKeyFormat.normalizedBare("\(bare)y")) + XCTAssertNil(SharedPubkyKeyFormat.normalizedBare("pubky\(bare)y")) + } + + func testDiscoveryFiltersMalformedAndOtherSourceAccountsWithoutLoadingPayloads() throws { + let (_, bare, _) = try identityFixture() + let accounts = [ + "app.pubkyring:\(bare)", + "app.pubkyring:\(bare)", + "to.bitkit:\(bare)", + "app.pubkyring:not-a-pubky", + "app.pubkyring:\(bare)y", + "unexpected:\(bare)", + ] + + let references = SharedPubkyIdentityVault.references(accounts: accounts, source: .ring) + + XCTAssertEqual(references, try [SharedPubkyIdentityRefV1(sourceApp: .ring, pubky: bare)]) + } + + func testBitkitOwnedDeletionCandidatesPreserveRingOwnedAccount() throws { + let (_, bare, _) = try identityFixture() + let accounts = [ + SharedPubkyIdentityVault.account(source: .ring, pubky: bare), + SharedPubkyIdentityVault.account(source: .bitkit, pubky: bare), + "to.bitkit:malformed-but-owned", + ] + + let exactDeletionAccounts = SharedPubkyIdentityVault.ownedAccounts( + accounts: accounts, + source: .bitkit + ) + + XCTAssertEqual( + exactDeletionAccounts, + ["to.bitkit:\(bare)", "to.bitkit:malformed-but-owned"].sorted() + ) + XCTAssertFalse(exactDeletionAccounts.contains("app.pubkyring:\(bare)")) + } + + func testReconciliationRemovesStaleBitkitMirrorAndPreservesRingMirror() throws { + let (_, bare, _) = try identityFixture() + let staleBare = String(repeating: "y", count: 52) + let currentAccount = SharedPubkyIdentityVault.account(source: .bitkit, pubky: bare) + let staleAccount = SharedPubkyIdentityVault.account(source: .bitkit, pubky: staleBare) + let ringAccount = SharedPubkyIdentityVault.account(source: .ring, pubky: staleBare) + var accounts = [currentAccount, staleAccount, ringAccount] + var deletedAccounts: [String] = [] + + try SharedPubkyIdentityVault.pruneStaleBitkitIdentities( + keeping: currentAccount, + listAccounts: { accounts }, + deleteAccount: { account in + deletedAccounts.append(account) + accounts.removeAll { $0 == account } + } + ) + + XCTAssertEqual(deletedAccounts, [staleAccount]) + XCTAssertEqual(accounts.sorted(), [currentAccount, ringAccount].sorted()) + } + + func testDestructiveDeletionErasesStaleBitkitMirrorAndPreservesRingMirror() throws { + let (_, bare, _) = try identityFixture() + let staleBare = String(repeating: "y", count: 52) + let currentAccount = SharedPubkyIdentityVault.account(source: .bitkit, pubky: bare) + let staleAccount = SharedPubkyIdentityVault.account(source: .bitkit, pubky: staleBare) + let ringAccount = SharedPubkyIdentityVault.account(source: .ring, pubky: staleBare) + var accounts = [currentAccount, staleAccount, ringAccount] + var deletedAccounts: [String] = [] + + try SharedPubkyIdentityVault.deleteOwnedBitkitIdentities( + including: currentAccount, + listAccounts: { accounts }, + deleteAccount: { account in + deletedAccounts.append(account) + accounts.removeAll { $0 == account } + } + ) + + XCTAssertEqual(deletedAccounts, [currentAccount, staleAccount]) + XCTAssertEqual(accounts, [ringAccount]) + } + + func testDestructiveDeletionFailsClosedWhileAnOwnedMirrorSurvives() throws { + let (_, bare, _) = try identityFixture() + let staleBare = String(repeating: "y", count: 52) + let currentAccount = SharedPubkyIdentityVault.account(source: .bitkit, pubky: bare) + let staleAccount = SharedPubkyIdentityVault.account(source: .bitkit, pubky: staleBare) + var accounts = [currentAccount, staleAccount] + + XCTAssertThrowsError(try SharedPubkyIdentityVault.deleteOwnedBitkitIdentities( + including: currentAccount, + listAccounts: { accounts }, + deleteAccount: { account in + guard account == currentAccount else { return } + accounts.removeAll { $0 == account } + } + )) { error in + XCTAssertEqual(error as? SharedPubkyIdentityError, .invalidRecord) + } + XCTAssertEqual(accounts, [staleAccount]) + } + + func testOrphanCleanupDeletesSharedMirrorBeforePrivateAndRNKeychains() throws { + var events: [String] = [] + + try OrphanedKeychainCleanup.perform( + hasNativeKeychain: true, + hasOrphanedRNKeychain: true, + deleteBitkitSharedIdentities: { events.append("shared") }, + wipePrivateKeychain: { events.append("private") }, + cleanupRNKeychain: { events.append("rn") } + ) + + XCTAssertEqual(events, ["shared", "private", "rn"]) + } + + func testOrphanCleanupPreservesPrivateStateWhenSharedMirrorDeletionFails() { + var events: [String] = [] + + XCTAssertThrowsError(try OrphanedKeychainCleanup.perform( + hasNativeKeychain: true, + hasOrphanedRNKeychain: true, + deleteBitkitSharedIdentities: { + events.append("shared") + throw SharedPubkyIdentityError.unavailable + }, + wipePrivateKeychain: { events.append("private") }, + cleanupRNKeychain: { events.append("rn") } + )) + + XCTAssertEqual(events, ["shared"]) + } + + func testCredentialValidationAcceptsMatchingSecret() throws { + let (_, bare, secret) = try identityFixture() + let reference = try SharedPubkyIdentityRefV1(sourceApp: .ring, pubky: bare) + let record = SharedPubkyIdentityRecordV1( + sourceApp: .ring, + pubky: bare, + secretKey: secret + ) + + XCTAssertNoThrow(try SharedPubkyIdentityVault.validate( + record: record, + expected: reference, + derivePublicKey: { try PubkyProfileManager.publicKeyFromSecretKey($0) } + )) + } + + func testLockedSharedKeychainStatusesAreRetryable() { + XCTAssertEqual( + SharedPubkyIdentityVault.error(for: errSecInteractionNotAllowed), + .temporarilyUnavailable + ) + XCTAssertEqual( + SharedPubkyIdentityVault.error(for: errSecNotAvailable), + .temporarilyUnavailable + ) + XCTAssertFalse(PubkyProfileManager.shouldDisconnectSharedIdentity(after: SharedPubkyIdentityError.temporarilyUnavailable)) + XCTAssertFalse(PubkyProfileManager.shouldDisconnectSharedIdentity(after: SharedPubkyIdentityError.unavailable)) + XCTAssertTrue(PubkyProfileManager.shouldDisconnectSharedIdentity(after: SharedPubkyIdentityError.sourceIdentityMissing)) + XCTAssertTrue(PubkyProfileManager.shouldDisconnectSharedIdentity(after: SharedPubkyIdentityError.invalidRecord)) + } + + func testValidatedSharedIdentityRetriesColdLaunchRestorationOnForeground() async throws { + var didRestore = false + + let result = try await PubkyProfileManager.retrySharedSessionRestorationIfNeeded( + currentPublicKey: nil, + restore: { + didRestore = true + return .restored(publicKey: "pubky-restored") + } + ) + + XCTAssertTrue(didRestore) + XCTAssertEqual(result, .restored(publicKey: "pubky-restored")) + } + + func testValidatedActiveSharedIdentityDoesNotRestoreAgain() async throws { + let result = try await PubkyProfileManager.retrySharedSessionRestorationIfNeeded( + currentPublicKey: "pubky-active", + restore: { + XCTFail("An active shared identity must not be restored again") + return .restorationFailed + } + ) + + XCTAssertNil(result) + } + + func testContactWriteRevalidatesBorrowedSourceImmediatelyBeforeWriting() async throws { + var events: [String] = [] + + let value = try await PubkyService.performContactWrite( + revalidateSource: { events.append("revalidate") }, + write: { + events.append("write") + return 42 + } + ) + + XCTAssertEqual(value, 42) + XCTAssertEqual(events, ["revalidate", "write"]) + } + + func testContactWriteStopsWhenBorrowedSourceWasRevoked() async { + var didWrite = false + + do { + _ = try await PubkyService.performContactWrite( + revalidateSource: { throw SharedPubkyIdentityError.sourceIdentityMissing }, + write: { + didWrite = true + return 42 + } + ) + XCTFail("Expected source revalidation to abort the contact write") + } catch { + XCTAssertEqual(error as? SharedPubkyIdentityError, .sourceIdentityMissing) + XCTAssertTrue(PubkyProfileManager.shouldDisconnectSharedIdentity(after: error)) + } + + XCTAssertFalse(didWrite) + } + + func testContactWriteStopsAndPreservesBorrowedIdentityOnTransientSourceFailure() async { + var didWrite = false + + do { + _ = try await PubkyService.performContactWrite( + revalidateSource: { throw SharedPubkyIdentityError.temporarilyUnavailable }, + write: { + didWrite = true + return 42 + } + ) + XCTFail("Expected transient source failure to abort the contact write") + } catch { + XCTAssertEqual(error as? SharedPubkyIdentityError, .temporarilyUnavailable) + XCTAssertFalse(PubkyProfileManager.shouldDisconnectSharedIdentity(after: error)) + } + + XCTAssertFalse(didWrite) + } + + func testCredentialValidationRejectsClaimedKeyMismatch() throws { + let (_, bare, secret) = try identityFixture() + let reference = try SharedPubkyIdentityRefV1(sourceApp: .ring, pubky: bare) + let record = SharedPubkyIdentityRecordV1( + sourceApp: .ring, + pubky: bare, + secretKey: secret + ) + let differentBare = String(repeating: "y", count: 52) + + XCTAssertThrowsError(try SharedPubkyIdentityVault.validate( + record: record, + expected: reference, + derivePublicKey: { _ in "pubky\(differentBare)" } + )) { error in + XCTAssertEqual(error as? SharedPubkyIdentityError, .secretDoesNotMatchPublicKey) + } + } + + func testCredentialValidationRejectsNoncanonicalSecretEncoding() throws { + let (_, bare, secret) = try identityFixture() + let reference = try SharedPubkyIdentityRefV1(sourceApp: .ring, pubky: bare) + let record = SharedPubkyIdentityRecordV1( + sourceApp: .ring, + pubky: bare, + secretKey: secret.uppercased() + ) + + XCTAssertThrowsError(try SharedPubkyIdentityVault.validate( + record: record, + expected: reference, + derivePublicKey: { _ in "pubky\(bare)" } + )) { error in + XCTAssertEqual(error as? SharedPubkyIdentityError, .invalidRecord) + } + } + + func testSharedSessionRestoreKeepsBareReferenceAtWireBoundary() async throws { + let (prefixed, bare, secret) = try identityFixture() + let reference = try SharedPubkyIdentityRefV1(sourceApp: .ring, pubky: bare) + + let result = await PubkyProfileManager.resolveSharedSessionInitialization( + reference: reference, + savedSessionSecret: "saved-session", + sharedSecretKey: secret, + importSession: { session in + XCTAssertEqual(session, "saved-session") + return prefixed + }, + signInWithSharedSecret: { _ in + XCTFail("A valid persisted session should not require the shared credential") + return "unused" + }, + currentPublicKey: { + XCTFail("A valid persisted session should not re-query SDK identity") + return nil + } + ) + + XCTAssertEqual(result, .restored(publicKey: prefixed)) + } + + func testSharedSessionRestoreUsesCredentialWithoutClassifyingItAsLocal() async throws { + let (prefixed, bare, secret) = try identityFixture() + let reference = try SharedPubkyIdentityRefV1(sourceApp: .ring, pubky: bare) + var receivedSecret: String? + + let result = await PubkyProfileManager.resolveSharedSessionInitialization( + reference: reference, + savedSessionSecret: nil, + sharedSecretKey: secret, + importSession: { _ in + XCTFail("No persisted session exists") + return "unused" + }, + signInWithSharedSecret: { value in + receivedSecret = value + return "fresh-session" + }, + currentPublicKey: { prefixed } + ) + + XCTAssertEqual(receivedSecret, secret) + XCTAssertEqual(result, .restored(publicKey: prefixed)) + } + + func testSharedIdentityAdoptionPersistsReferenceBeforeSession() async throws { + let (prefixed, bare, secret) = try identityFixture() + let reference = try SharedPubkyIdentityRefV1(sourceApp: .ring, pubky: bare) + var events: [String] = [] + + let result = try await PubkyProfileManager.establishSharedIdentitySession( + reference: reference, + secretKey: secret, + saveReference: { saved in + XCTAssertEqual(saved, reference) + events.append("reference") + }, + signIn: { receivedSecret in + XCTAssertEqual(receivedSecret, secret) + events.append("session") + return "fresh-session" + }, + currentPublicKey: { prefixed }, + clearSession: { + XCTFail("Successful adoption should not clear its session") + }, + deleteReference: { + XCTFail("Successful adoption should not delete its reference") + } + ) + + XCTAssertEqual(result, prefixed) + XCTAssertEqual(events, ["reference", "session"]) + } + + func testSharedIdentityAdoptionRollsBackReferenceAndSessionOnFailure() async throws { + let (_, bare, secret) = try identityFixture() + let reference = try SharedPubkyIdentityRefV1(sourceApp: .ring, pubky: bare) + var events: [String] = [] + + do { + _ = try await PubkyProfileManager.establishSharedIdentitySession( + reference: reference, + secretKey: secret, + saveReference: { _ in events.append("reference") }, + signIn: { _ in + events.append("session") + throw PubkyServiceError.authFailed("offline") + }, + currentPublicKey: { nil }, + clearSession: { events.append("clear-session") }, + deleteReference: { events.append("delete-reference") } + ) + XCTFail("Expected failed sign-in to roll back") + } catch { + XCTAssertEqual( + events, + ["reference", "session", "clear-session", "delete-reference"] + ) + } + } + + func testSharedIdentityCleanupClearsSessionBeforeReference() async throws { + var events: [String] = [] + + try await PubkyProfileManager.clearSharedIdentitySession( + clearSession: { events.append("session") }, + deleteReference: { events.append("reference") } + ) + + XCTAssertEqual(events, ["session", "reference"]) + } + + func testSharedIdentityCleanupKeepsReferenceWhenSessionDeletionFails() async { + var events: [String] = [] + + do { + try await PubkyProfileManager.clearSharedIdentitySession( + clearSession: { + events.append("session") + throw KeychainError.failedToDelete + }, + deleteReference: { events.append("reference") } + ) + XCTFail("Expected session deletion failure") + } catch { + XCTAssertEqual(events, ["session"]) + } + } + + @MainActor + func testUnavailableSharedIdentityCleanupRemovesEndpointsBeforeSessionAndLocalState() async throws { + var events: [String] = [] + + try await PubkyProfileManager.clearUnavailableSharedIdentitySession( + removePrivatePaykitEndpoints: { + events.append("private-endpoints") + return true + }, + removePublicPaykitEndpoints: { + events.append("public-endpoints") + return true + }, + clearSession: { events.append("session") }, + clearPrivatePaykitState: { events.append("private-state") }, + clearPaykitSharingState: { events.append("sharing-state") }, + deleteReference: { events.append("reference") } + ) + + XCTAssertEqual( + events, + ["private-endpoints", "public-endpoints", "session", "private-state", "sharing-state", "reference"] + ) + } + + @MainActor + func testUnavailableSharedIdentityCleanupKeepsLocalStateWhenSessionDeletionFails() async { + var events: [String] = [] + + do { + try await PubkyProfileManager.clearUnavailableSharedIdentitySession( + removePrivatePaykitEndpoints: { + events.append("private-endpoints") + return true + }, + removePublicPaykitEndpoints: { + events.append("public-endpoints") + return true + }, + clearSession: { + events.append("session") + throw KeychainError.failedToDelete + }, + clearPrivatePaykitState: { events.append("private-state") }, + clearPaykitSharingState: { events.append("sharing-state") }, + deleteReference: { events.append("reference") } + ) + XCTFail("Expected session deletion failure") + } catch { + XCTAssertEqual(events, ["private-endpoints", "public-endpoints", "session"]) + } + } + + @MainActor + func testUnavailableSharedIdentityCleanupContinuesAfterBestEffortEndpointFailures() async throws { + var events: [String] = [] + + try await PubkyProfileManager.clearUnavailableSharedIdentitySession( + removePrivatePaykitEndpoints: { + events.append("private-endpoints-failed") + return false + }, + removePublicPaykitEndpoints: { + events.append("public-endpoints-failed") + return false + }, + clearSession: { events.append("session") }, + clearPrivatePaykitState: { events.append("private-state") }, + clearPaykitSharingState: { events.append("sharing-state") }, + deleteReference: { events.append("reference") } + ) + + XCTAssertEqual( + events, + [ + "private-endpoints-failed", + "public-endpoints-failed", + "session", + "private-state", + "sharing-state", + "reference", + ] + ) + } + + func testIdentityLifecycleTransactionsDoNotInterleave() async { + let events = SharedPubkyTestEventLog() + let firstStarted = expectation(description: "first lifecycle transaction started") + + async let first: Void = PubkyProfileManager.withIdentityLifecycleLock { + await events.append("first-start") + firstStarted.fulfill() + try? await Task.sleep(nanoseconds: 100_000_000) + await events.append("first-end") + } + await fulfillment(of: [firstStarted], timeout: 1) + async let second: Void = PubkyProfileManager.withIdentityLifecycleLock { + await events.append("second") + } + + _ = await (first, second) + let recordedEvents = await events.values() + XCTAssertEqual(recordedEvents, ["first-start", "first-end", "second"]) + } + + func testSharedSourceRevalidationFailsClosedForRevokedAndUnavailableSources() throws { + let (_, bare, secret) = try identityFixture() + let reference = try SharedPubkyIdentityRefV1(sourceApp: .ring, pubky: bare) + + XCTAssertThrowsError(try PubkyProfileManager.validateSharedIdentitySource( + reference: reference, + isSourceAvailable: true, + loadSharedCredential: { _ in throw SharedPubkyIdentityError.sourceIdentityMissing } + )) { error in + XCTAssertEqual(error as? SharedPubkyIdentityError, .sourceIdentityMissing) + } + + XCTAssertThrowsError(try PubkyProfileManager.validateSharedIdentitySource( + reference: reference, + isSourceAvailable: false, + loadSharedCredential: { _ in + XCTFail("An unavailable source must fail before reading shared credentials") + return secret + } + )) { error in + XCTAssertEqual(error as? SharedPubkyIdentityError, .sourceUnavailable) + } + + XCTAssertNoThrow(try PubkyProfileManager.validateSharedIdentitySource( + reference: nil, + isSourceAvailable: false, + loadSharedCredential: { _ in + XCTFail("An owned identity must never read the shared vault") + return secret + } + )) + } + + func testProfileDeletionRevalidatesBorrowedSourceBeforeErasingContacts() async throws { + var events: [String] = [] + + do { + try await PubkyProfileManager.deleteProfileWithContactCleanup( + revalidateSource: { + events.append("revalidate") + throw SharedPubkyIdentityError.sourceIdentityMissing + }, + deleteContacts: { events.append("contacts") }, + deleteProfile: { events.append("profile") } + ) + XCTFail("Expected a revoked source to abort profile deletion") + } catch { + XCTAssertEqual(error as? SharedPubkyIdentityError, .sourceIdentityMissing) + } + + XCTAssertEqual(events, ["revalidate"]) + + events = [] + try await PubkyProfileManager.deleteProfileWithContactCleanup( + revalidateSource: { events.append("revalidate") }, + deleteContacts: { events.append("contacts") }, + deleteProfile: { events.append("profile") } + ) + + XCTAssertEqual(events, ["revalidate", "contacts", "profile"]) + } + + func testActiveIdentityRejectsLocalAndSharedProvenanceCoexistence() throws { + let (prefixed, bare, secret) = try identityFixture() + let reference = try SharedPubkyIdentityRefV1(sourceApp: .ring, pubky: bare) + + XCTAssertThrowsError(try PubkyProfileManager.resolveActiveIdentitySecretKey( + expectedPublicKey: prefixed, + reference: reference, + localSecret: secret, + isSourceAvailable: true, + loadSharedCredential: { _ in + XCTFail("A provenance conflict must fail before reading shared credentials") + return secret + } + )) { error in + XCTAssertEqual(error as? SharedPubkyIdentityError, .provenanceConflict) + } + } + + func testActiveOwnedIdentityMustMatchExpectedPublicKey() throws { + let (_, _, secret) = try identityFixture() + let differentPublicKey = "pubky\(String(repeating: "y", count: 52))" + + XCTAssertThrowsError(try PubkyProfileManager.resolveActiveIdentitySecretKey( + expectedPublicKey: differentPublicKey, + reference: nil, + localSecret: secret, + isSourceAvailable: false, + loadSharedCredential: { _ in secret } + )) { error in + XCTAssertEqual(error as? SharedPubkyIdentityError, .provenanceConflict) + } + } + + func testBorrowedSessionNeverPersistsExportedLocalSecret() throws { + let (_, _, secret) = try identityFixture() + let exportedLocalSecret = try PaykitSdkService.localSecretKey(fromHex: secret) + + XCTAssertNil(try PaykitSdkService.localSecretKeyHexForPersistence( + exportedLocalSecret, + shouldStoreLocalSecret: false + )) + XCTAssertEqual( + try PaykitSdkService.localSecretKeyHexForPersistence( + exportedLocalSecret, + shouldStoreLocalSecret: true + ), + secret + ) + XCTAssertThrowsError(try PaykitSdkService.localSecretKeyHexForPersistence( + nil, + shouldStoreLocalSecret: true + )) + } + + private func identityFixture() throws -> (prefixed: String, bare: String, secret: String) { + let secret = try PubkyService.derivePubkySecretKey(mnemonic: mnemonic) + let prefixed = try PubkyProfileManager.publicKeyFromSecretKey(secret) + let bare = try XCTUnwrap(SharedPubkyKeyFormat.normalizedBare(prefixed)) + return (prefixed, bare, secret) + } +} diff --git a/BitkitTests/WalletViewModelReceiveTests.swift b/BitkitTests/WalletViewModelReceiveTests.swift index e62c13eaf..e4e91b181 100644 --- a/BitkitTests/WalletViewModelReceiveTests.swift +++ b/BitkitTests/WalletViewModelReceiveTests.swift @@ -3,6 +3,48 @@ import XCTest @MainActor final class WalletViewModelReceiveTests: XCTestCase { + func testChannelUsabilityRefreshHonorsPaykitMaintenancePermission() { + var pendingRefresh = false + XCTAssertTrue(WalletViewModel.shouldRefreshPaykitAfterChannelChange( + allowPaykitMaintenance: true, + hadUsableChannels: false, + hasUsableChannels: true, + pendingRefresh: &pendingRefresh + )) + XCTAssertFalse(pendingRefresh) + XCTAssertFalse(WalletViewModel.shouldRefreshPaykitAfterChannelChange( + allowPaykitMaintenance: false, + hadUsableChannels: false, + hasUsableChannels: true, + pendingRefresh: &pendingRefresh + )) + XCTAssertTrue(pendingRefresh) + XCTAssertTrue(WalletViewModel.shouldRefreshPaykitAfterChannelChange( + allowPaykitMaintenance: true, + hadUsableChannels: true, + hasUsableChannels: true, + pendingRefresh: &pendingRefresh + )) + XCTAssertFalse(pendingRefresh) + } + + func testEventDrivenPaykitMaintenanceRemainsSuspendedAfterFailedValidation() { + let wallet = WalletViewModel() + var pendingRefresh = false + + XCTAssertFalse(wallet.isPaykitMaintenanceAllowed) + wallet.setPaykitMaintenanceAllowed(false) + + XCTAssertFalse(wallet.isPaykitMaintenanceAllowed) + XCTAssertFalse(WalletViewModel.shouldRefreshPaykitAfterChannelChange( + allowPaykitMaintenance: wallet.isPaykitMaintenanceAllowed, + hadUsableChannels: false, + hasUsableChannels: true, + pendingRefresh: &pendingRefresh + )) + XCTAssertTrue(pendingRefresh) + } + func testReceiveLightningInvoiceRequiresReadyChannel() { let wallet = WalletViewModel() wallet.channels = [ diff --git a/changelog.d/next/643.added.md b/changelog.d/next/643.added.md new file mode 100644 index 000000000..b78c83012 --- /dev/null +++ b/changelog.d/next/643.added.md @@ -0,0 +1 @@ +Added secure reuse of Pubky Ring profiles through source-owned shared identities, including the redesigned profile choice screen.