diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 76754898e..41a77410c 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -66,7 +66,10 @@ struct AppScene: View { PaykitFeatureFlags.enforceBuildAvailability() ContactPaymentsService.enableAllPaymentOptions() - _app = StateObject(wrappedValue: AppViewModel(sheetViewModel: sheetViewModel, navigationViewModel: navigationViewModel)) + _app = StateObject(wrappedValue: AppViewModel( + sheetViewModel: sheetViewModel, + navigationViewModel: navigationViewModel + )) _sheets = StateObject(wrappedValue: sheetViewModel) _navigation = StateObject(wrappedValue: navigationViewModel) let feeEstimatesManager = FeeEstimatesManager() @@ -878,6 +881,10 @@ struct AppScene: View { wallet.resetSendState(speed: settings.defaultTransactionSpeed) return } + } catch ScanHandlingError.pubkyAuthRequest { + guard paykitPaymentRequestManager.isCurrentPresentation(request) else { return } + _ = paykitPaymentRequestManager.markPresentedIfPending(request) + continue } catch is CancellationError { if app.ownsContactPaymentContext(contactPaymentContext) { app.resetSendState() diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index a1b201357..c66cf2fcf 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -1,5 +1,35 @@ import SwiftUI +enum PendingProfileSetupResumeState { + case inactive + case waiting + case ready + + func shouldResume(didResume: inout Bool) -> Bool { + if self == .inactive { + didResume = false + } + guard self == .ready, !didResume else { return false } + didResume = true + return true + } +} + +func resolvePendingProfileSetupResumeState( + isProfileSetupPending: Bool, + isPaykitUIActive: Bool, + isAuthenticated: Bool, + hasActiveSheet: Bool, + isReplacingSheet: Bool, + currentRoute: Route? +) -> PendingProfileSetupResumeState { + guard isProfileSetupPending else { return .inactive } + guard isPaykitUIActive, isAuthenticated, !hasActiveSheet, !isReplacingSheet, currentRoute != .createProfile else { + return .waiting + } + return .ready +} + struct MainNavView: View { @AppStorage(PaykitFeatureFlags.uiEnabledKey) private var isPaykitUIEnabled = false @@ -20,11 +50,23 @@ struct MainNavView: View { @State private var showClipboardAlert = false @State private var clipboardUri: String? + @State private var didResumePendingPubkyProfileSetup = false private var isPaykitUIActive: Bool { PaykitFeatureFlags.isUIAvailable && isPaykitUIEnabled } + private var pendingProfileSetupResumeState: PendingProfileSetupResumeState { + resolvePendingProfileSetupResumeState( + isProfileSetupPending: pubkyProfile.isProfileSetupPending, + isPaykitUIActive: isPaykitUIActive, + isAuthenticated: pubkyProfile.isAuthenticated, + hasActiveSheet: sheets.activeSheetConfiguration != nil, + isReplacingSheet: sheets.isReplacingSheet, + currentRoute: navigation.currentRoute + ) + } + // Delay constants for clipboard processing private static let nodeReadyDelayNanoseconds: UInt64 = 500_000_000 // 0.5 seconds private static let statePropagationDelayNanoseconds: UInt64 = 500_000_000 // 0.5 seconds @@ -39,6 +81,10 @@ struct MainNavView: View { navigation.navigate(.spendingHwSigned) } } + .onChange(of: pendingProfileSetupResumeState, initial: true) { _, resumeState in + guard resumeState.shouldResume(didResume: &didResumePendingPubkyProfileSetup) else { return } + navigation.navigate(.createProfile) + } .sheet( item: $sheets.addTagSheetItem, onDismiss: { diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 2c0001981..6f507d27d 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -1,4 +1,5 @@ import Foundation +import struct Paykit.PubkySessionBootstrapResult import SwiftUI enum PubkyAuthState: Equatable { @@ -121,6 +122,10 @@ private enum PubkyProfileManagerError: LocalizedError { } } +enum PubkySignupError: Error { + case alreadySignedIn +} + @MainActor class PubkyProfileManager: ObservableObject { enum SessionInitializationResult: Equatable { @@ -138,12 +143,14 @@ class PubkyProfileManager: ObservableObject { @Published var sessionRestorationFailed = false @Published private(set) var cachedName: String? @Published private(set) var cachedImageUri: String? + @Published private(set) var isProfileSetupPending: Bool private var activeAuthAttemptID: UUID? init() { cachedName = UserDefaults.standard.string(forKey: Self.cachedNameKey) cachedImageUri = UserDefaults.standard.string(forKey: Self.cachedImageUriKey) + isProfileSetupPending = UserDefaults.standard.bool(forKey: Self.profileSetupPendingKey) } // MARK: - Initialization & Session Restoration @@ -250,71 +257,175 @@ class PubkyProfileManager: ObservableObject { links: [PubkyProfileLink], tags: [String] = [], existingImageUrl: String? = nil, - avatarImage: UIImage? = nil + avatarImage: UIImage? = nil, + loadStoredSecretKey: () async throws -> String? = { + try await Task.detached { try Keychain.loadString(key: .pubkySecretKey) }.value + } ) async throws { - let (publicKeyZ32, secretKeyHex) = try await deriveKeys() - - _ = try await Task.detached { - let signupDetails: (homeserverPubky: String, signupCode: String?) - if let homeserverPubky = Env.e2eHomeserverPubky { - signupDetails = (homeserverPubky, nil) - } else { - let homegate = try await Self.fetchHomegateSignupCode() - signupDetails = (homegate.homeserverPubky, homegate.signupCode) - } + if isProfileSetupPending, let publicKey { + try await createProfile( + publicKey: publicKey, + name: name, + bio: bio, + links: links, + tags: tags, + existingImageUrl: existingImageUrl, + avatarImage: avatarImage + ) + return + } - var session: String - do { - session = try await PubkyService.signUp( - secretKeyHex: secretKeyHex, - homeserverZ32: signupDetails.homeserverPubky, - signupCode: signupDetails.signupCode + setProfileSetupPending(false) + try await Self.completeIdentityCreation( + loadStoredSecretKey: loadStoredSecretKey, + signIn: { secretKeyHex in + try await Task.detached { + _ = try await PubkyService.signIn(secretKeyHex: secretKeyHex) + return try Self.publicKeyFromSecretKey(secretKeyHex) + }.value + }, + signUp: { + let (publicKey, secretKeyHex) = try await self.deriveKeys() + _ = try await Task.detached { + let signupDetails: (homeserverPubky: String, signupCode: String?) + if let homeserverPubky = Env.e2eHomeserverPubky { + signupDetails = (homeserverPubky, nil) + } else { + let homegate = try await Self.fetchHomegateSignupCode() + signupDetails = (homegate.homeserverPubky, homegate.signupCode) + } + + do { + return try await PubkyService.signUp( + secretKeyHex: secretKeyHex, + homeserverZ32: signupDetails.homeserverPubky, + signupCode: signupDetails.signupCode + ) + } catch { + Logger.info("signUp failed (likely already registered), trying signIn: \(error)", context: "PubkyProfileManager") + return try await PubkyService.signIn(secretKeyHex: secretKeyHex) + } + }.value + return publicKey + }, + createProfile: { publicKey in + try await self.createProfile( + publicKey: publicKey, + name: name, + bio: bio, + links: links, + tags: tags, + existingImageUrl: existingImageUrl, + avatarImage: avatarImage ) - } catch { - Logger.info("signUp failed (likely already registered), trying signIn: \(error)", context: "PubkyProfileManager") - session = try await PubkyService.signIn(secretKeyHex: secretKeyHex) - } + }, + discardSessionAccess: { await self.discardAbandonedSession() } + ) + } - return session - }.value + static func completeIdentityCreation( + loadStoredSecretKey: () async throws -> String?, + signIn: (String) async throws -> String, + signUp: () async throws -> String, + createProfile: (String) async throws -> Void, + discardSessionAccess: () async -> Void + ) async throws { + if let secretKeyHex = try await loadStoredSecretKey(), !secretKeyHex.isEmpty { + let publicKey = try await signIn(secretKeyHex) + try await createProfile(publicKey) + return + } + let publicKey = try await signUp() do { - var avatarUri: String? - if let avatarImage { - avatarUri = try await uploadAvatar(image: avatarImage) - } - let resolvedImageUrl = Self.resolvedImageUrl(newImageUrl: avatarUri, existingImageUrl: existingImageUrl) + try await createProfile(publicKey) + } catch { + await discardSessionAccess() + throw error + } + } - try await writeProfile( - name: name, - bio: bio, - imageUrl: resolvedImageUrl, - links: links, - tags: tags - ) - Self.notifyAppStateBackupChanged() + private func createProfile( + publicKey: String, + name: String, + bio: String, + links: [PubkyProfileLink], + tags: [String], + existingImageUrl: String?, + avatarImage: UIImage? + ) async throws { + var avatarUri: String? + if let avatarImage { + avatarUri = try await uploadAvatar(image: avatarImage) + } + let imageUrl = Self.resolvedImageUrl(newImageUrl: avatarUri, existingImageUrl: existingImageUrl) - let createdProfile = PubkyProfile( - publicKey: publicKeyZ32, - name: name, - bio: bio, - imageUrl: resolvedImageUrl, - links: links, - tags: tags, - status: nil - ) + try await writeProfile(name: name, bio: bio, imageUrl: imageUrl, links: links, tags: tags) + Self.notifyAppStateBackupChanged() - publicKey = publicKeyZ32 - authState = .authenticated - profile = createdProfile - cacheProfileMetadata(createdProfile) - } catch { - let profileCreationError = error - await discardAbandonedSession() - throw profileCreationError + let createdProfile = PubkyProfile( + publicKey: publicKey, + name: name, + bio: bio, + imageUrl: imageUrl, + links: links, + tags: tags, + status: nil + ) + self.publicKey = publicKey + authState = .authenticated + profile = createdProfile + cacheProfileMetadata(createdProfile) + setProfileSetupPending(false) + } + + func approveSignupAuth(request: PubkyAuthRequest) async throws { + guard request.isSignup, let homeserver = request.homeserverPublicKey else { + throw PubkyServiceError.invalidAuthUrl + } + guard publicKey == nil, try !Self.hasStoredIdentity() else { + throw PubkySignupError.alreadySignedIn + } + + let (publicKey, secretKeyHex) = try await deriveKeys() + guard self.publicKey == nil, try !Self.hasStoredIdentity() else { + throw PubkySignupError.alreadySignedIn } - Logger.info("Pubky identity created for \(publicKeyZ32)", context: "PubkyProfileManager") + try await completeSignupAuthentication( + publicKey: publicKey, + registerIdentity: { + try await PubkyService.registerIdentity( + secretKeyHex: secretKeyHex, + homeserverZ32: homeserver, + signupCode: request.signupToken + ) + }, + approveAuth: { + if let authorizationUrl = request.authorizationUrl { + try await PubkyService.approveRingAuth(authUrl: authorizationUrl, secretKeyHex: secretKeyHex) + } + }, + activateIdentity: { try await PubkyService.activateRegisteredIdentity($0) } + ) + } + + private func completeSignupAuthentication( + publicKey: String, + registerIdentity: () async throws -> PubkySessionBootstrapResult, + approveAuth: () async throws -> Void, + activateIdentity: (PubkySessionBootstrapResult) async throws -> Void + ) async throws { + setProfileSetupPending(false) + let registeredSession = try await registerIdentity() + try await approveAuth() + try await activateIdentity(registeredSession) + + UserDefaults.standard.set(false, forKey: PrivatePaykitService.publishingEnabledKey) + self.publicKey = publicKey + authState = .authenticated + setProfileSetupPending(true) + Self.notifyAppStateBackupChanged() } func saveProfile( @@ -648,6 +759,20 @@ class PubkyProfileManager: ObservableObject { } #if DEBUG + func completeSignupAuthenticationForTesting( + publicKey: String, + registerIdentity: () async throws -> PubkySessionBootstrapResult, + approveAuth: () async throws -> Void, + activateIdentity: (PubkySessionBootstrapResult) async throws -> Void + ) async throws { + try await completeSignupAuthentication( + publicKey: publicKey, + registerIdentity: registerIdentity, + approveAuth: approveAuth, + activateIdentity: activateIdentity + ) + } + func setActiveAuthAttemptIDForTesting(_ attemptID: UUID?) { activeAuthAttemptID = attemptID } @@ -736,6 +861,7 @@ class PubkyProfileManager: ObservableObject { await PubkyImageCache.shared.clear() UserDefaults.standard.removeObject(forKey: cachedNameKey) UserDefaults.standard.removeObject(forKey: cachedImageUriKey) + UserDefaults.standard.removeObject(forKey: profileSetupPendingKey) ContactsManager.restoreContactProfileOverrides(nil) clearPublicPaykitSharingState() notifyAppStateBackupChanged() @@ -829,6 +955,7 @@ class PubkyProfileManager: ObservableObject { throw error } + setProfileSetupPending(false) clearAuthenticatedState() } @@ -871,6 +998,7 @@ class PubkyProfileManager: ObservableObject { private static let cachedNameKey = "pubky_profile_name" private static let cachedImageUriKey = "pubky_profile_image_uri" + private static let profileSetupPendingKey = "pubky_profile_setup_pending" var displayName: String? { profile?.name ?? cachedName @@ -894,6 +1022,11 @@ class PubkyProfileManager: ObservableObject { UserDefaults.standard.removeObject(forKey: Self.cachedImageUriKey) } + private func setProfileSetupPending(_ pending: Bool) { + isProfileSetupPending = pending + UserDefaults.standard.set(pending, forKey: Self.profileSetupPendingKey) + } + private func clearAuthenticatedState() { publicKey = nil profile = nil @@ -920,6 +1053,15 @@ class PubkyProfileManager: ObservableObject { Self.hasLocalSecretKey(for: publicKey) } + nonisolated static func hasStoredIdentity() throws -> Bool { + for key in [KeychainEntryType.paykitSession, .pubkySecretKey] { + if let value = try Keychain.loadString(key: key), !value.isEmpty { + return true + } + } + return false + } + nonisolated static func hasLocalSecretKey(for publicKey: String?) -> Bool { guard let publicKey, let secretKeyHex = try? Keychain.loadString(key: .pubkySecretKey), diff --git a/Bitkit/Models/PubkyAuthRequest.swift b/Bitkit/Models/PubkyAuthRequest.swift index 4a785ca16..2758c7bf8 100644 --- a/Bitkit/Models/PubkyAuthRequest.swift +++ b/Bitkit/Models/PubkyAuthRequest.swift @@ -1,3 +1,4 @@ +import BitkitCore import Foundation import Paykit @@ -48,7 +49,7 @@ struct PubkyAuthPermission { } } -// MARK: - PubkyAuth Request (parsed from pubkyauth:// URL) +// MARK: - PubkyAuth Request struct PubkyAuthRequest { let rawUrl: String @@ -59,14 +60,113 @@ struct PubkyAuthRequest { let permissions: [PubkyAuthPermission] let serviceNames: [String] let bitkitClaim: PubkyAuthClaim? + let homeserverPublicKey: String? + let signupToken: String? + let authorizationUrl: String? + + var isSignup: Bool { + Self.isSignupURL(rawUrl) + } static func isProtocolURL(_ value: String) -> Bool { - URLComponents(string: value.trimmingCharacters(in: .whitespacesAndNewlines))?.scheme?.lowercased() == "pubkyauth" + guard let components = URLComponents(string: value.trimmingCharacters(in: .whitespacesAndNewlines)) else { + return false + } + + switch components.scheme?.lowercased() { + case "pubkyauth": + return true + case "pubkyring": + return components.host?.lowercased() == "signup" + default: + return false + } } static func parse(url: String) throws -> PubkyAuthRequest { + if let components = URLComponents(string: url), isSignupURL(components) { + return try parseSignup(url: url, components: components) + } + let details = try Paykit.parsePubkyAuthUrl(authUrl: url) - let capabilities = details.capabilities ?? "" + let capabilities = details.capabilities + return try makeRequest( + url: url, + kind: details.kind, + clientID: details.clientId, + relay: details.relayUrl, + capabilities: capabilities, + homeserverPublicKey: nil, + signupToken: nil, + authorizationUrl: url + ) + } + + static func isSignupURL(_ value: String) -> Bool { + guard let components = URLComponents(string: value) else { return false } + return isSignupURL(components) + } + + private static func isSignupURL(_ components: URLComponents) -> Bool { + switch components.scheme?.lowercased() { + case "pubkyring": + return components.host?.lowercased() == "signup" + case "pubkyauth": + return ["direct_signup", "signup"].contains(components.host?.lowercased()) + default: + return false + } + } + + private static func parseSignup(url: String, components: URLComponents) throws -> PubkyAuthRequest { + let values = Dictionary(grouping: components.queryItems ?? [], by: \.name) + let homeserver = try requiredQueryValue("hs", from: values) + let authorizesApp = components.scheme?.lowercased() == "pubkyring" || + ( + components.scheme?.lowercased() == "pubkyauth" && + components.host?.lowercased() == "signup" && + ["relay", "secret", "caps"].contains { values[$0] != nil } + ) + let relay = authorizesApp ? try requiredQueryValue("relay", from: values) : "" + let secret = authorizesApp ? try requiredQueryValue("secret", from: values) : "" + let capabilities = authorizesApp ? try requiredQueryValue("caps", from: values) : "" + let authorizationUrl = authorizesApp + ? ringAuthorizationUrl(relay: relay, secret: secret, capabilities: capabilities) + : nil + do { + if let authorizationUrl { + _ = try BitkitCore.parsePubkyAuthUrl(authUrl: authorizationUrl) + } + _ = try Paykit.normalizePubkyPublicKey(value: homeserver) + } catch { + throw PubkyAuthRequestError.invalidUrl + } + let request = try makeRequest( + url: url, + kind: .signUp, + clientID: "", + relay: relay, + capabilities: capabilities, + homeserverPublicKey: homeserver, + signupToken: optionalQueryValue("st", from: values), + authorizationUrl: authorizationUrl + ) + guard request.bitkitClaim == nil else { + throw PubkyAuthRequestError.invalidUrl + } + return request + } + + private static func makeRequest( + url: String, + kind: Paykit.PubkyAuthRequestKind, + clientID: String, + relay: String, + capabilities: String, + homeserverPublicKey: String?, + signupToken: String?, + authorizationUrl: String? + ) throws -> PubkyAuthRequest { let permissions = parseCapabilities(capabilities) var seenServiceNames = Set() let serviceNames = permissions @@ -75,16 +175,50 @@ struct PubkyAuthRequest { let bitkitClaim = try parseBitkitClaim(url: url, capabilities: capabilities) return PubkyAuthRequest( rawUrl: url, - kind: details.kind, - clientID: details.clientId, - relay: details.relayUrl ?? "", + kind: kind, + clientID: clientID, + relay: relay, capabilities: capabilities, permissions: permissions, serviceNames: serviceNames, - bitkitClaim: bitkitClaim + bitkitClaim: bitkitClaim, + homeserverPublicKey: homeserverPublicKey, + signupToken: signupToken, + authorizationUrl: authorizationUrl ) } + private static func ringAuthorizationUrl(relay: String, secret: String, capabilities: String) -> String { + "pubkyauth:///?relay=\(encodeQueryComponent(relay))" + + "&secret=\(encodeQueryComponent(secret))&caps=\(encodeQueryComponent(capabilities))" + } + + private static func encodeQueryComponent(_ value: String) -> String { + let unreserved = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-._~")) + return value.addingPercentEncoding(withAllowedCharacters: unreserved) ?? value + } + + private static func requiredQueryValue( + _ name: String, + from values: [String: [URLQueryItem]] + ) throws -> String { + guard let value = try optionalQueryValue(name, from: values), !value.isEmpty else { + throw PubkyAuthRequestError.invalidUrl + } + return value + } + + private static func optionalQueryValue( + _ name: String, + from values: [String: [URLQueryItem]] + ) throws -> String? { + let items = values[name] ?? [] + guard items.count <= 1 else { + throw PubkyAuthRequestError.invalidUrl + } + return items.first?.value.flatMap { $0.isEmpty ? nil : $0 } + } + static func parseBitkitClaim(url: String, capabilities: String) throws -> PubkyAuthClaim? { guard let components = URLComponents(string: url) else { throw PubkyAuthRequestError.invalidUrl diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 7fe9ba826..8871ecc61 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -710,11 +710,14 @@ "pubky_auth__success_middle" = " and gave the service permission to access and edit your "; "pubky_auth__success_suffix" = " data."; "pubky_auth__biometric_failed" = "Authentication Failed"; +"pubky_auth__already_signed_in" = "Already signed in"; "pubky_auth__no_identity" = "Pubky Identity Required"; "pubky_auth__no_identity_desc" = "Create a Pubky identity in your profile to approve auth requests."; "pubky_auth__use_ring" = "Use Pubky Ring"; "pubky_auth__use_ring_desc" = "Your identity was created with Pubky Ring. Open Ring to approve this request."; "pubky_auth__invalid_request" = "Invalid auth request"; +"pubky_auth__homeserver" = "Homeserver"; +"pubky_auth__signup_description" = "Create a new Pubky identity on this homeserver. Only continue if you trust it."; "pubky_auth__approval_failed" = "Authorization Failed"; "watch_only_accounts__active_section" = "Active accounts"; "watch_only_accounts__copy_xpub" = "Copy xpub"; diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index da4d702ab..d1524862f 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -98,6 +98,12 @@ enum PubkyService { ) } + static func approveRingAuth(authUrl: String, secretKeyHex: String) async throws { + try await ServiceQueue.background(.core) { + try await BitkitCore.approvePubkyAuth(authUrl: authUrl, secretKeyHex: secretKeyHex) + } + } + static func approveAuthWithCompanionClaim( authUrl: String, approvedClientID: String, @@ -224,6 +230,22 @@ enum PubkyService { return result.sessionAccess.exportSessionSecret() } + static func registerIdentity( + secretKeyHex: String, + homeserverZ32: String, + signupCode: String? = nil + ) async throws -> PubkySessionBootstrapResult { + try await PaykitSdkService.shared.registerIdentity( + secretKeyHex: secretKeyHex, + homeserverPublicKey: homeserverZ32, + signupCode: signupCode + ) + } + + static func activateRegisteredIdentity(_ result: PubkySessionBootstrapResult) async throws { + try await PaykitSdkService.shared.activateRegisteredIdentity(result) + } + /// Sign in with an existing secret key. Returns new session secret. static func signIn(secretKeyHex: String) async throws -> String { let result = try await PaykitSdkService.shared.signIn(secretKeyHex: secretKeyHex) @@ -394,6 +416,38 @@ actor PaykitSdkService { } } + func registerIdentity( + secretKeyHex: String, + homeserverPublicKey: String, + signupCode: String? + ) async throws -> PubkySessionBootstrapResult { + try await operationLock.withLock { + try await bootstrap().signUp( + localSecretKey: Self.localSecretKey(fromHex: secretKeyHex), + receiverNoiseSecretKey: sessionProvider.loadOrDeriveReceiverNoiseSecretKey(), + homeserverPublicKey: homeserverPublicKey, + signupCode: signupCode, + requiredCapabilities: Self.requiredCapabilities() + ) + } + } + + func activateRegisteredIdentity(_ result: PubkySessionBootstrapResult) async throws { + try await operationLock.withLock { + let previousPublicKey = await currentSdkStatePublicKey() + do { + try await activateBootstrapResult(result, previousPublicKey: previousPublicKey, shouldStoreLocalSecret: true) + } catch { + try? sessionProvider.clearSessionAccess() + try? Keychain.delete(key: .paykitSdkState) + resetRuntime() + markWalletBackupDataChanged() + throw error + } + markWalletBackupDataChanged() + } + } + func signIn(secretKeyHex: String) async throws -> PubkySessionBootstrapResult { try await operationLock.withLock { let previousPublicKey = await currentSdkStatePublicKey() diff --git a/Bitkit/Utilities/ShopPaymentRequest.swift b/Bitkit/Utilities/ShopPaymentRequest.swift index 5c014bdce..e2fa56de2 100644 --- a/Bitkit/Utilities/ShopPaymentRequest.swift +++ b/Bitkit/Utilities/ShopPaymentRequest.swift @@ -24,6 +24,7 @@ enum ShopPaymentRequest { } enum ScanHandlingError: LocalizedError { + case pubkyAuthRequest case unsupportedRequest var errorDescription: String? { diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 4fb599759..14764c2f3 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -245,7 +245,10 @@ class AppViewModel: ObservableObject { /// Convenience initializer for previews and testing convenience init() { - self.init(sheetViewModel: SheetViewModel(), navigationViewModel: NavigationViewModel()) + self.init( + sheetViewModel: SheetViewModel(), + navigationViewModel: NavigationViewModel() + ) } deinit {} @@ -437,8 +440,15 @@ extension AppViewModel { } let uri = uri.removingLightningSchemes() + if let claimedContactPaymentContext, PubkyAuthRequest.isProtocolURL(uri) { + releaseContactPaymentContext(claimedContactPaymentContext) + throw ScanHandlingError.pubkyAuthRequest + } let prevalidatedPaymentRequest: BitkitCore.Scanner? if scope == .paymentRequests { + if PubkyAuthRequest.isProtocolURL(uri) { + throw ScanHandlingError.pubkyAuthRequest + } guard SamRockSetupRequest.parse(uri) == nil, !SamRockSetupRequest.isProtocolURL(uri) else { @@ -485,6 +495,23 @@ extension AppViewModel { return } + if PubkyAuthRequest.isProtocolURL(uri) { + guard scope == .unrestricted else { + throw ScanHandlingError.pubkyAuthRequest + } + guard PaykitFeatureFlags.isUIEnabled else { + toast( + type: .error, + title: t("other__scan_err_decoding"), + description: t("other__scan__error__generic"), + accessibilityIdentifier: "InvalidAddressToast" + ) + return + } + await handlePubkyAuthApproval(uri) + return + } + let data: BitkitCore.Scanner if let prevalidatedPaymentRequest { data = prevalidatedPaymentRequest @@ -659,7 +686,7 @@ extension AppViewModel { ) return } - handlePubkyAuthApproval(authUrl) + await handlePubkyAuthApproval(authUrl) case let .gift(code, amount): sheetViewModel.showSheet(.gift, data: GiftConfig(code: code, amount: Int(amount))) default: @@ -786,29 +813,55 @@ extension AppViewModel { sheetViewModel.showSheet(.lnurlAuth, data: LnurlAuthConfig(lnurl: lnurl, authData: data)) } - private func handlePubkyAuthApproval(_ authUrl: String) { - // State 1: No Pubky identity at all - guard (try? Keychain.loadString(key: .paykitSession))?.isEmpty == false else { + private func handlePubkyAuthApproval(_ authUrl: String) async { + let request: PubkyAuthRequest + + do { + request = try PubkyAuthRequest.parse(url: authUrl) + } catch { + Logger.error("Failed to parse pubky auth URL: \(error)", context: "AppViewModel") + sheetViewModel.hideSheetIfActive(.scanner, reason: "Invalid Pubky auth request") + toast(type: .error, title: t("pubky_auth__invalid_request")) + return + } + + if request.isSignup { + do { + guard try !PubkyProfileManager.hasStoredIdentity() else { + sheetViewModel.hideSheetIfActive(.scanner, reason: "Pubky identity already exists") + toast(type: .info, title: t("pubky_auth__already_signed_in")) + return + } + } catch { + Logger.error("Failed to read stored Pubky identity: \(error)", context: "AppViewModel") + sheetViewModel.hideSheetIfActive(.scanner, reason: "Pubky identity check failed") + toast(type: .error, title: t("pubky_auth__approval_failed"), description: error.localizedDescription) + return + } + + sheetViewModel.showSheet( + .pubkyAuthApproval, + data: PubkyAuthApprovalConfig(request: request) + ) + return + } + + let hasSession = (try? Keychain.loadString(key: .paykitSession))?.isEmpty == false + guard hasSession else { + sheetViewModel.hideSheetIfActive(.scanner, reason: "Pubky identity is missing") toast(type: .warning, title: t("pubky_auth__no_identity"), description: t("pubky_auth__no_identity_desc")) return } - // State 2: Ring-authenticated (has session but no local secret key) guard let secretKey = try? Keychain.loadString(key: .pubkySecretKey), !secretKey.isEmpty 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 } - // State 3: Bitkit-generated identity — can approve - do { - let request = try PubkyAuthRequest.parse(url: authUrl) - sheetViewModel.showSheet(.pubkyAuthApproval, data: PubkyAuthApprovalConfig(authUrl: authUrl, request: request)) - } catch { - Logger.error("Failed to parse pubky auth URL: \(error)", context: "AppViewModel") - toast(type: .error, title: t("pubky_auth__invalid_request")) - } + sheetViewModel.showSheet(.pubkyAuthApproval, data: PubkyAuthApprovalConfig(request: request)) } private func handleNodeUri(_ url: String) { @@ -826,6 +879,11 @@ extension AppViewModel { contactPaymentContext?.id == context.id } + private func releaseContactPaymentContext(_ context: ContactPaymentContext) { + guard ownsContactPaymentContext(context) else { return } + contactPaymentContext = nil + } + func resetSendState(preservingContactPaymentContext: Bool = false) { scannedLightningInvoice = nil scannedOnchainInvoice = nil diff --git a/Bitkit/ViewModels/SheetViewModel.swift b/Bitkit/ViewModels/SheetViewModel.swift index c226c657e..3a4951b99 100644 --- a/Bitkit/ViewModels/SheetViewModel.swift +++ b/Bitkit/ViewModels/SheetViewModel.swift @@ -267,8 +267,8 @@ class SheetViewModel: ObservableObject { get { guard let config = activeSheetConfiguration, config.id == .pubkyAuthApproval else { return nil } let pubkyConfig = config.data as? PubkyAuthApprovalConfig - guard let authUrl = pubkyConfig?.authUrl, let request = pubkyConfig?.request else { return nil } - return PubkyAuthApprovalSheetItem(authUrl: authUrl, request: request) + guard let request = pubkyConfig?.request else { return nil } + return PubkyAuthApprovalSheetItem(request: request) } set { if newValue == nil { diff --git a/Bitkit/Views/Profile/CreateProfileView.swift b/Bitkit/Views/Profile/CreateProfileView.swift index a0a848db9..3081f1c7a 100644 --- a/Bitkit/Views/Profile/CreateProfileView.swift +++ b/Bitkit/Views/Profile/CreateProfileView.swift @@ -146,10 +146,8 @@ struct CreateProfileView: View { private var loadingView: some View { VStack(spacing: 12) { - Spacer() ActivityIndicator(size: 32) BodyMText(t("profile__deriving_keys"), textColor: .white64) - Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) } diff --git a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift index c7842ee46..17c6179c4 100644 --- a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift +++ b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift @@ -32,14 +32,12 @@ func pubkyAuthDisplayPublicKey(_ publicKey: String?) -> String { } struct PubkyAuthApprovalConfig { - let authUrl: String let request: PubkyAuthRequest } struct PubkyAuthApprovalSheetItem: SheetItem { let id: SheetID = .pubkyAuthApproval let size: SheetSize = .large - let authUrl: String let request: PubkyAuthRequest } @@ -228,23 +226,51 @@ struct PubkyAuthApprovalSheet: View { GeometryReader { geometry in ScrollView { VStack(alignment: .leading, spacing: 0) { - descriptionText - .padding(.bottom, 8) + if config.request.isSignup { + BodyMText(t("pubky_auth__signup_description")) + .padding(.bottom, 16) + } - BodySText(t("pubky_auth__requester", variables: ["clientId": config.request.clientID])) - .lineLimit(1) - .truncationMode(.tail) - .padding(.bottom, 32) + if !config.request.permissions.isEmpty { + descriptionText + .padding(.bottom, 8) + } - permissionsSection + if !config.request.clientID.isEmpty { + BodySText(t("pubky_auth__requester", variables: ["clientId": config.request.clientID])) + .lineLimit(1) + .truncationMode(.tail) + .padding(.bottom, 32) + } else { + Spacer().frame(height: 24) + } + + if !config.request.permissions.isEmpty { + permissionsSection + } Spacer(minLength: 32) trustWarning .padding(.bottom, 16) - profileCard + if let homeserver = config.request.homeserverPublicKey { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("pubky_auth__homeserver"), textColor: .white64) + BodyMSBText(homeserver) + .textSelection(.enabled) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(24) + .background(Color.gray6) + .cornerRadius(16) .padding(.bottom, 16) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("PubkySignupHomeserver") + } else { + profileCard + .padding(.bottom, 16) + } } .frame(minHeight: geometry.size.height, alignment: .top) } @@ -379,6 +405,15 @@ struct PubkyAuthApprovalSheet: View { private func performAuthorization() async { guard state == .authorizing else { return } do { + if config.request.isSignup { + try await pubkyProfile.approveSignupAuth(request: config.request) + guard sheets.pubkyAuthApprovalSheetItem?.request.rawUrl == config.request.rawUrl else { + return + } + sheets.hideSheet() + return + } + guard let secretKey = try Keychain.loadString(key: .pubkySecretKey), !secretKey.isEmpty else { @@ -389,13 +424,18 @@ struct PubkyAuthApprovalSheet: View { try await PubkyService.approveAuthRequest( request: config.request, - authUrl: config.authUrl, + authUrl: config.request.rawUrl, accountName: watchOnlyAccountName, secretKeyHex: secretKey ) state = .success } catch { + if case PubkySignupError.alreadySignedIn = error { + app.toast(type: .info, title: t("pubky_auth__already_signed_in")) + sheets.hideSheet() + return + } Logger.error("Failed to approve pubky auth: \(error)", context: "PubkyAuthApprovalSheet") app.toast(type: .error, title: t("pubky_auth__approval_failed"), description: error.localizedDescription) state = .authorize diff --git a/BitkitTests/PendingProfileSetupResumeTests.swift b/BitkitTests/PendingProfileSetupResumeTests.swift new file mode 100644 index 000000000..b5f77e2e2 --- /dev/null +++ b/BitkitTests/PendingProfileSetupResumeTests.swift @@ -0,0 +1,87 @@ +@testable import Bitkit +import XCTest + +final class PendingProfileSetupResumeTests: XCTestCase { + func testResumeReadiness() { + let cases: [(pending: Bool, paykitActive: Bool, authenticated: Bool, sheet: Bool, replacing: Bool, route: Route?, + expected: PendingProfileSetupResumeState)] = [ + (false, true, true, false, false, nil, .inactive), + (false, false, false, true, true, .createProfile, .inactive), + (true, false, true, false, false, nil, .waiting), + (true, true, false, false, false, nil, .waiting), + (true, true, true, true, false, nil, .waiting), + (true, true, true, false, true, nil, .waiting), + (true, true, true, false, false, .createProfile, .waiting), + (true, true, true, false, false, nil, .ready), + (true, true, true, false, false, .settings, .ready), + ] + + for (index, testCase) in cases.enumerated() { + XCTAssertEqual( + resolvePendingProfileSetupResumeState( + isProfileSetupPending: testCase.pending, + isPaykitUIActive: testCase.paykitActive, + isAuthenticated: testCase.authenticated, + hasActiveSheet: testCase.sheet, + isReplacingSheet: testCase.replacing, + currentRoute: testCase.route + ), + testCase.expected, + "Case \(index)" + ) + } + } + + func testWaitingPreservesResumeLatchUntilPendingSetupClears() { + for alreadyResumed in [false, true] { + var didResume = alreadyResumed + let waiting = resolvePendingProfileSetupResumeState( + isProfileSetupPending: true, + isPaykitUIActive: true, + isAuthenticated: true, + hasActiveSheet: true, + isReplacingSheet: false, + currentRoute: nil + ) + + XCTAssertFalse(waiting.shouldResume(didResume: &didResume)) + XCTAssertEqual(didResume, alreadyResumed) + XCTAssertEqual(PendingProfileSetupResumeState.ready.shouldResume(didResume: &didResume), !alreadyResumed) + XCTAssertTrue(didResume) + XCTAssertFalse(PendingProfileSetupResumeState.ready.shouldResume(didResume: &didResume)) + + let inactive = resolvePendingProfileSetupResumeState( + isProfileSetupPending: false, + isPaykitUIActive: true, + isAuthenticated: false, + hasActiveSheet: false, + isReplacingSheet: false, + currentRoute: nil + ) + XCTAssertFalse(inactive.shouldResume(didResume: &didResume)) + XCTAssertFalse(didResume) + XCTAssertTrue(PendingProfileSetupResumeState.ready.shouldResume(didResume: &didResume)) + } + } + + func testLeavingCreateProfileDoesNotResumeAgain() { + var didResume = false + var resumedRoutes: [Route] = [] + + for route: Route? in [nil, .createProfile, nil, .settings] { + let state = resolvePendingProfileSetupResumeState( + isProfileSetupPending: true, + isPaykitUIActive: true, + isAuthenticated: true, + hasActiveSheet: false, + isReplacingSheet: false, + currentRoute: route + ) + if state.shouldResume(didResume: &didResume) { + resumedRoutes.append(.createProfile) + } + } + + XCTAssertEqual(resumedRoutes, [.createProfile]) + } +} diff --git a/BitkitTests/PubkyAuthRequestTests.swift b/BitkitTests/PubkyAuthRequestTests.swift index cb4cb80a2..11fab1bb8 100644 --- a/BitkitTests/PubkyAuthRequestTests.swift +++ b/BitkitTests/PubkyAuthRequestTests.swift @@ -5,21 +5,68 @@ import XCTest final class PubkyAuthRequestTests: XCTestCase { private let relay = "https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F" private let secret = "e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s" - private let clientPublicKey = "5jsjx1o6fzu6aeeo697r3i5rx15zq41kikcye8wtwdqm4nb4tryo" + private let publicKey = "5jsjx1o6fzu6aeeo697r3i5rx15zq41kikcye8wtwdqm4nb4tryo" func testProtocolUrlRecognizesPubkyAuthSchemeCaseInsensitively() { XCTAssertTrue(PubkyAuthRequest.isProtocolURL("pubkyauth://signin?caps=/pub/bitkit.to/:rw")) XCTAssertTrue(PubkyAuthRequest.isProtocolURL("PUBKYAUTH://signin?caps=/pub/bitkit.to/:rw")) XCTAssertTrue(PubkyAuthRequest.isProtocolURL(" pubkyauth://signin?caps=/pub/bitkit.to/:rw\n")) + XCTAssertTrue(PubkyAuthRequest.isProtocolURL(ringSignupUrl())) + XCTAssertTrue(PubkyAuthRequest.isProtocolURL(directSignupUrl(action: "direct_signup"))) XCTAssertFalse(PubkyAuthRequest.isProtocolURL("lightning:lnbc1example")) } + func testParseAuthorizedSignup() throws { + for scheme in ["pubkyring", "pubkyauth"] { + let request = try PubkyAuthRequest.parse(url: ringSignupUrl(signupToken: "invite code", scheme: scheme)) + + XCTAssertTrue(request.isSignup) + XCTAssertEqual(request.kind, .signUp) + XCTAssertEqual(request.homeserverPublicKey, publicKey) + XCTAssertEqual(request.signupToken, "invite code") + XCTAssertEqual(request.relay, "https://relay.example/inbox/") + XCTAssertEqual(request.capabilities, "/pub/example.app/:rw") + XCTAssertEqual( + request.authorizationUrl, + "pubkyauth:///?relay=https%3A%2F%2Frelay.example%2Finbox%2F" + + "&secret=\(secret)&caps=%2Fpub%2Fexample.app%2F%3Arw" + ) + } + } + + func testParseDirectSignupAcceptsCanonicalAndLegacyFormats() throws { + for action in ["direct_signup", "signup"] { + let request = try PubkyAuthRequest.parse(url: directSignupUrl(action: action, signupToken: "invite code")) + + XCTAssertTrue(request.isSignup) + XCTAssertEqual(request.kind, .signUp) + XCTAssertEqual(request.homeserverPublicKey, publicKey) + XCTAssertEqual(request.signupToken, "invite code") + XCTAssertEqual(request.relay, "") + XCTAssertEqual(request.capabilities, "") + XCTAssertNil(request.authorizationUrl) + } + } + + func testParseRingSignupRejectsMissingOrDuplicateRequiredValues() { + let invalidUrls = [ + ringSignupUrl().replacingOccurrences(of: "&secret=\(secret)", with: ""), + "\(ringSignupUrl())&hs=other", + directSignupUrl(action: "signup") + "&relay=https%3A%2F%2Frelay.example", + ] + + for url in invalidUrls { + XCTAssertThrowsError(try PubkyAuthRequest.parse(url: url)) + } + } + func testParseUrlPreservesRequestedCapabilities() throws { let capabilities = "/pub/bitkit.to/:rw" let url = authUrl(capabilities: capabilities) let request = try PubkyAuthRequest.parse(url: url) + XCTAssertFalse(request.isSignup) XCTAssertEqual(request.clientID, "paykit.test") XCTAssertEqual(request.capabilities, capabilities) XCTAssertEqual(request.permissions.count, 1) @@ -262,6 +309,22 @@ final class PubkyAuthRequestTests: XCTestCase { .map { "&\(PubkyAuthClaim.queryParameter)=\($0)" } .joined() return "pubkyauth://signin_grant?caps=\(capabilities)&relay=\(relay)&secret=\(secret)" + - "&cid=paykit.test&cpk=\(clientPublicKey)\(claims)" + "&cid=paykit.test&cpk=\(publicKey)\(claims)" + } + + private func ringSignupUrl(signupToken: String? = nil, scheme: String = "pubkyring") -> String { + let token = signupToken.map { + "&st=\($0.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? $0)" + } ?? "" + return "\(scheme)://signup?hs=\(publicKey)" + + "&relay=https%3A%2F%2Frelay.example%2Finbox%2F" + + "&secret=\(secret)&caps=%2Fpub%2Fexample.app%2F%3Arw\(token)" + } + + private func directSignupUrl(action: String, signupToken: String? = nil) -> String { + let token = signupToken.map { + "&st=\($0.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? $0)" + } ?? "" + return "pubkyauth://\(action)?hs=\(publicKey)\(token)" } } diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index 77ebf71fc..6727ac3cd 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -1,7 +1,164 @@ @testable import Bitkit +import class Paykit.PubkySessionAccess +import struct Paykit.PubkySessionBootstrapResult import XCTest final class PubkyProfileManagerTests: XCTestCase { + @MainActor + func testIdentityRestorationPreservesCredentialsForRetry() async throws { + for failedStep in ["load", "signIn", "profile"] { + for failure in [PubkyServiceError.authFailed("offline") as Error, CancellationError()] { + var storedKey: String? = "existing-key" + var shouldFail = true + var profilePublicKey: String? + + func complete() async throws { + try await PubkyProfileManager.completeIdentityCreation( + loadStoredSecretKey: { + if shouldFail, failedStep == "load" { throw failure } + return storedKey + }, + signIn: { + XCTAssertEqual($0, "existing-key") + if shouldFail, failedStep == "signIn" { throw failure } + return "pubky_existing" + }, + signUp: { + XCTFail("An existing identity must not be registered on another homeserver") + return "pubky_new" + }, + createProfile: { + if shouldFail, failedStep == "profile" { throw failure } + profilePublicKey = $0 + }, + discardSessionAccess: { + storedKey = nil + XCTFail("Recovery must preserve the existing identity") + } + ) + } + + do { + try await complete() + XCTFail("Expected recovery to fail") + } catch { + XCTAssertEqual(error is CancellationError, failure is CancellationError) + XCTAssertEqual(error.localizedDescription, failure.localizedDescription) + } + XCTAssertNil(profilePublicKey) + XCTAssertEqual(storedKey, "existing-key") + + shouldFail = false + try await complete() + XCTAssertEqual(profilePublicKey, "pubky_existing") + XCTAssertEqual(storedKey, "existing-key") + } + } + } + + @MainActor + func testIdentityCreationWithoutLocalKeyKeepsSignupAndCleanup() async throws { + for storedKey in [nil, ""] as [String?] { + for failsToSaveProfile in [false, true] { + var didSignUp = false + var didDiscard = false + var profilePublicKey: String? + + do { + try await PubkyProfileManager.completeIdentityCreation( + loadStoredSecretKey: { storedKey }, + signIn: { _ in + XCTFail("No local identity exists to restore") + return "pubky_existing" + }, + signUp: { + didSignUp = true + return "pubky_new" + }, + createProfile: { + if failsToSaveProfile { throw PubkyServiceError.authFailed("profile") } + profilePublicKey = $0 + }, + discardSessionAccess: { didDiscard = true } + ) + XCTAssertFalse(failsToSaveProfile) + } catch { + XCTAssertTrue(failsToSaveProfile) + } + + XCTAssertTrue(didSignUp) + XCTAssertEqual(didDiscard, failsToSaveProfile) + XCTAssertEqual(profilePublicKey, failsToSaveProfile ? nil : "pubky_new") + } + } + } + + @MainActor + func testCreateIdentityRecoversStalePendingSetupWithoutPublicKey() async { + let defaults = UserDefaults.standard + let previousPending = defaults.object(forKey: "pubky_profile_setup_pending") + defer { defaults.set(previousPending, forKey: "pubky_profile_setup_pending") } + defaults.set(true, forKey: "pubky_profile_setup_pending") + let manager = KeyDerivationProbeProfileManager() + + do { + try await manager.createIdentity(name: "Test", bio: "", links: [], loadStoredSecretKey: { nil }) + XCTFail("Expected key derivation probe to stop creation") + } catch { + XCTAssertTrue(manager.didDeriveKeys) + XCTAssertFalse(manager.isProfileSetupPending) + } + } + + @MainActor + func testSignupFinishesProfileSetupOnlyAfterActivation() async throws { + let defaults = UserDefaults.standard + let previousPending = defaults.object(forKey: "pubky_profile_setup_pending") + let previousSharing = defaults.object(forKey: PrivatePaykitService.publishingEnabledKey) + defer { + defaults.set(previousPending, forKey: "pubky_profile_setup_pending") + defaults.set(previousSharing, forKey: PrivatePaykitService.publishingEnabledKey) + } + + for failingStep in [nil, "register", "authorize", "activate"] { + defaults.set(true, forKey: "pubky_profile_setup_pending") + let manager = PubkyProfileManager() + let session = PubkySessionBootstrapResult(sessionAccess: PubkySessionAccess(noPointer: .init()), publicKey: "pubky_test") + var events: [String] = [] + func perform(_ step: String) throws { + XCTAssertFalse(manager.isProfileSetupPending) + XCTAssertNil(manager.publicKey) + events.append(step) + if step == failingStep { throw PubkyServiceError.authFailed(step) } + } + + do { + try await manager.completeSignupAuthenticationForTesting( + publicKey: "pubky_test", + registerIdentity: { + try perform("register") + return session + }, + approveAuth: { try perform("authorize") }, + activateIdentity: { + XCTAssertTrue($0.sessionAccess === session.sessionAccess) + try perform("activate") + } + ) + XCTAssertNil(failingStep) + XCTAssertEqual(events, ["register", "authorize", "activate"]) + XCTAssertTrue(manager.isProfileSetupPending) + XCTAssertEqual(manager.publicKey, "pubky_test") + XCTAssertEqual(manager.authState, .authenticated) + } catch { + XCTAssertEqual(events.last, failingStep) + XCTAssertFalse(manager.isProfileSetupPending) + XCTAssertNil(manager.publicKey) + XCTAssertEqual(manager.authState, .idle) + } + } + } + // MARK: - Ring callbacks func testPubkyRingAuthURLBuilderAddsXCallbackParams() throws { @@ -861,6 +1018,16 @@ final class PubkyProfileManagerTests: XCTestCase { } } +@MainActor +private class KeyDerivationProbeProfileManager: PubkyProfileManager { + var didDeriveKeys = false + + override func deriveKeys() async throws -> (String, String) { + didDeriveKeys = true + throw PubkyServiceError.authFailed("key derivation probe") + } +} + private func XCTAssertThrowsErrorAsync( _ expression: () async throws -> some Any, file: StaticString = #filePath, diff --git a/BitkitTests/ShopPaymentRequestTests.swift b/BitkitTests/ShopPaymentRequestTests.swift index a3304d82f..ec457ace8 100644 --- a/BitkitTests/ShopPaymentRequestTests.swift +++ b/BitkitTests/ShopPaymentRequestTests.swift @@ -18,23 +18,67 @@ final class ShopPaymentRequestTests: XCTestCase { XCTAssertFalse(ShopPaymentRequest.isOnchainPayment(.lightning(invoice: lightningInvoice))) } - func testNonPaymentRequestDoesNotClearExistingPaymentState() async { + func testNonPaymentRequestsDoNotClearExistingPaymentState() async { let app = AppViewModel() + let requests = [ + "https://btcpay.example/plugins/store123/samrock/protocol?setup=btc-chain&otp=abc123", + pubkySignupUrl, + directPubkySignupUrl, + ] + + for request in requests { + app.scannedLightningInvoice = lightningInvoice + do { + try await app.handleScannedData(request, scope: .paymentRequests) + XCTFail("Expected the shop payment scope to reject a non-payment request") + } catch { + XCTAssertTrue(error is ScanHandlingError) + } + XCTAssertNotNil(app.scannedLightningInvoice) + } + } + + func testContactPaymentRejectsPubkySignupWithoutClearingSendState() async { + let app = AppViewModel() + let context = ContactPaymentContext(publicKey: "pubkycontact") + XCTAssertTrue(app.claimContactPaymentContext(context)) app.scannedLightningInvoice = lightningInvoice do { - try await app.handleScannedData( - "https://btcpay.example/plugins/store123/samrock/protocol?setup=btc-chain&otp=abc123", - scope: .paymentRequests - ) - XCTFail("Expected the shop payment scope to reject a setup request") + try await app.handleScannedData(pubkySignupUrl, claimedContactPaymentContext: context) + XCTFail("Expected contact payment to reject Pubky signup") } catch { XCTAssertTrue(error is ScanHandlingError) } + XCTAssertFalse(app.ownsContactPaymentContext(context)) XCTAssertNotNil(app.scannedLightningInvoice) } + func testSignupScannerRoutesRequireApproval() async throws { + let defaults = UserDefaults.standard + let previousEnabled = defaults.object(forKey: PaykitFeatureFlags.uiEnabledKey) + defer { defaults.set(previousEnabled, forKey: PaykitFeatureFlags.uiEnabledKey) } + defaults.set(true, forKey: PaykitFeatureFlags.uiEnabledKey) + XCTAssertFalse(try PubkyProfileManager.hasStoredIdentity()) + + for url in [pubkySignupUrl, directPubkySignupUrl, directPubkySignupUrl.replacingOccurrences(of: "direct_signup", with: "signup")] { + let sheets = SheetViewModel() + let app = AppViewModel( + sheetViewModel: sheets, + navigationViewModel: NavigationViewModel() + ) + + try await app.handleScannedData(url) + + XCTAssertEqual(sheets.activeSheetConfiguration?.id, .pubkyAuthApproval) + let config = try XCTUnwrap(sheets.activeSheetConfiguration?.data as? PubkyAuthApprovalConfig) + XCTAssertTrue(config.request.isSignup) + XCTAssertEqual(config.request.homeserverPublicKey, "5jsjx1o6fzu6aeeo697r3i5rx15zq41kikcye8wtwdqm4nb4tryo") + XCTAssertFalse(try PubkyProfileManager.hasStoredIdentity()) + } + } + private var lightningInvoice: LightningInvoice { LightningInvoice( bolt11: "test-invoice", @@ -49,6 +93,17 @@ final class ShopPaymentRequestTests: XCTestCase { ) } + private var pubkySignupUrl: String { + "pubkyring://signup?hs=5jsjx1o6fzu6aeeo697r3i5rx15zq41kikcye8wtwdqm4nb4tryo" + + "&relay=https%3A%2F%2Frelay.example%2Finbox%2F" + + "&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s" + + "&caps=%2Fpub%2Fexample%2F%3Arw" + } + + private var directPubkySignupUrl: String { + "pubkyauth://direct_signup?hs=5jsjx1o6fzu6aeeo697r3i5rx15zq41kikcye8wtwdqm4nb4tryo&st=invite" + } + private var onchainInvoice: OnChainInvoice { OnChainInvoice( address: "bcrt1qexample", diff --git a/changelog.d/next/724.added.md b/changelog.d/next/724.added.md new file mode 100644 index 000000000..fe1fcd278 --- /dev/null +++ b/changelog.d/next/724.added.md @@ -0,0 +1 @@ +Added support for creating a Pubky identity from app-authorized and direct Pubky signup requests.