From 53865694789582e7734877605e1c71c9f6f7ab05 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 31 Aug 2026 13:49:10 -0500 Subject: [PATCH 01/40] feat: upgrade paykit auth to rc50 --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- Bitkit/Managers/PubkyProfileManager.swift | 68 ++++++++++++------- Bitkit/Services/PubkyService.swift | 52 ++++++-------- BitkitTests/PaykitSdkClientConfigTests.swift | 6 ++ BitkitTests/PubkyProfileManagerTests.swift | 18 ++--- changelog.d/next/paykit-rc50.security.md | 1 + 7 files changed, 83 insertions(+), 68 deletions(-) create mode 100644 changelog.d/next/paykit-rc50.security.md diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index eb927124a..e428fe937 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1182,7 +1182,7 @@ repositoryURL = "https://github.com/pubky/paykit-rs"; requirement = { kind = exactVersion; - version = "0.1.0-rc46"; + version = "0.1.0-rc50"; }; }; 18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 2b1adf6fb..8167000f0 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pubky/paykit-rs", "state" : { - "revision" : "09e388d82f70d02be9860b8b6ac108ae722a3fb9", - "version" : "0.1.0-rc46" + "revision" : "49f09ee439884d1ba2708f50e66022cda22cf10f", + "version" : "0.1.0-rc50" } }, { diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index b3da17c3d..acc607749 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -309,10 +309,15 @@ class PubkyProfileManager: ObservableObject { profile = createdProfile cacheProfileMetadata(createdProfile) } catch { - try? Keychain.delete(key: .pubkySecretKey) - try? Keychain.delete(key: .paykitSession) - await PubkyService.forceSignOut() - throw error + let profileCreationError = error + do { + try await Task.detached { + try await PubkyService.signOut() + }.value + } catch { + Logger.warn("Failed to revoke incomplete Pubky profile session: \(error)", context: "PubkyProfileManager") + } + throw profileCreationError } Logger.info("Pubky identity created for \(publicKeyZ32)", context: "PubkyProfileManager") @@ -507,7 +512,11 @@ class PubkyProfileManager: ObservableObject { try await completeAuthentication( completeAuth: { _ = try await PubkyService.completeAuth() }, currentPublicKey: { await PubkyService.currentPublicKey() }, - clearSessionAccess: { await PubkyService.clearSessionAccess() } + revokeSessionAccess: { + try await Task.detached { + try await PubkyService.signOut() + }.value + } ) } @@ -515,7 +524,7 @@ class PubkyProfileManager: ObservableObject { private func completeAuthentication( completeAuth: @escaping () async throws -> Void, currentPublicKey: @escaping () async -> String?, - clearSessionAccess: @escaping () async -> Void + revokeSessionAccess: @escaping () async throws -> Void ) async throws -> String { guard let attemptID = activeAuthAttemptID else { throw CancellationError() @@ -548,14 +557,14 @@ class PubkyProfileManager: ObservableObject { await loadProfile() return pk } catch is CancellationError { - await clearCompletedAuthSessionIfNeeded(didCompleteAuth, clearSessionAccess: clearSessionAccess) + await revokeCompletedAuthSessionIfNeeded(didCompleteAuth, revokeSessionAccess: revokeSessionAccess) if activeAuthAttemptID == attemptID { activeAuthAttemptID = nil restoreAuthStateAfterAuthFlow() } throw CancellationError() } catch let serviceError as PubkyServiceError { - await clearCompletedAuthSessionIfNeeded(didCompleteAuth, clearSessionAccess: clearSessionAccess) + await revokeCompletedAuthSessionIfNeeded(didCompleteAuth, revokeSessionAccess: revokeSessionAccess) guard activeAuthAttemptID == attemptID else { throw CancellationError() } @@ -564,7 +573,7 @@ class PubkyProfileManager: ObservableObject { restoreAuthStateAfterAuthFlow() throw serviceError } catch { - await clearCompletedAuthSessionIfNeeded(didCompleteAuth, clearSessionAccess: clearSessionAccess) + await revokeCompletedAuthSessionIfNeeded(didCompleteAuth, revokeSessionAccess: revokeSessionAccess) guard activeAuthAttemptID == attemptID else { throw CancellationError() } @@ -575,9 +584,16 @@ class PubkyProfileManager: ObservableObject { } } - private func clearCompletedAuthSessionIfNeeded(_ didCompleteAuth: Bool, clearSessionAccess: @escaping () async -> Void) async { + private func revokeCompletedAuthSessionIfNeeded( + _ didCompleteAuth: Bool, + revokeSessionAccess: @escaping () async throws -> Void + ) async { guard didCompleteAuth else { return } - await clearSessionAccess() + do { + try await revokeSessionAccess() + } catch { + Logger.warn("Failed to revoke canceled Pubky auth session: \(error)", context: "PubkyProfileManager") + } } func finalizeAuthentication() { @@ -614,12 +630,12 @@ class PubkyProfileManager: ObservableObject { func completeAuthenticationForTesting( completeAuth: @escaping () async throws -> Void, currentPublicKey: @escaping () async -> String?, - clearSessionAccess: @escaping () async -> Void + revokeSessionAccess: @escaping () async throws -> Void ) async throws -> String { try await completeAuthentication( completeAuth: completeAuth, currentPublicKey: currentPublicKey, - clearSessionAccess: clearSessionAccess + revokeSessionAccess: revokeSessionAccess ) } #endif @@ -666,11 +682,17 @@ class PubkyProfileManager: ObservableObject { // MARK: - Sign Out static func clearLocalState() async { + do { + try await PubkyService.forgetSessionAccess() + } catch { + Logger.warn("Failed to forget local Pubky session access: \(error)", context: "PubkyProfileManager") + } + await clearLocalAppState() + } + + private static func clearLocalAppState() async { await PrivatePaykitService.shared.closeAndClear() await PrivatePaykitAddressReservationStore.shared.clearContactAssignments() - await PubkyService.forceSignOut() - try? Keychain.delete(key: .paykitSession) - try? Keychain.delete(key: .pubkySecretKey) await PubkyImageCache.shared.clear() UserDefaults.standard.removeObject(forKey: cachedNameKey) UserDefaults.standard.removeObject(forKey: cachedImageUriKey) @@ -752,12 +774,8 @@ class PubkyProfileManager: ObservableObject { try await Self.removePrivatePaykitEndpoints(context: "PubkyProfileManager.signOut") } await Self.removePublicPaykitEndpointsBestEffort(context: "PubkyProfileManager.signOut") - do { - try await PubkyService.signOut() - } catch { - Logger.warn("Server sign out failed, forcing local sign out: \(error)", context: "PubkyProfileManager") - } - await Self.clearLocalState() + try await PubkyService.signOut() + await Self.clearLocalAppState() }.value clearAuthenticatedState() @@ -871,8 +889,8 @@ class PubkyProfileManager: ObservableObject { deleteKeychainValue: (KeychainEntryType) throws -> Void = { try Keychain.delete(key: $0) }, - clearSessionAccess: @escaping () async -> Void = { - await PubkyService.clearSessionAccess() + forgetSessionAccess: @escaping () async throws -> Void = { + try await PubkyService.forgetSessionAccess() }, signInWithSecretKey: @escaping (String) async throws -> String = { try await PubkyService.signIn(secretKeyHex: $0) @@ -881,7 +899,7 @@ class PubkyProfileManager: ObservableObject { try await PubkyService.importExternalSession(secret: $0) } ) async throws { - await clearSessionAccess() + try await forgetSessionAccess() switch backup?.kind { case .none: diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 4b5045f44..458ff0a76 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -274,12 +274,8 @@ enum PubkyService { try await PaykitSdkService.shared.signOut() } - static func forceSignOut() async { - await PaykitSdkService.shared.forceSignOut() - } - - static func clearSessionAccess() async { - await PaykitSdkService.shared.clearSessionAccess() + static func forgetSessionAccess() async throws { + try await PaykitSdkService.shared.forgetSessionAccess() } } @@ -817,25 +813,11 @@ actor PaykitSdkService { resetRuntime() } - func forceSignOut() async { - await operationLock.withLock { - sessionProvider.clearLiveSessionAccess() - try? Keychain.delete(key: .paykitSession) - try? Keychain.delete(key: .pubkySecretKey) - clearStateLocked() - } - } - - func clearSessionAccess() async { - await operationLock.withLock { - sessionProvider.clearLiveSessionAccess() - try? Keychain.delete(key: .paykitSession) - try? Keychain.delete(key: .pubkySecretKey) - activeAuthRequest = nil - activeAuthRequestID = nil - resetRuntime() - markWalletBackupDataChanged() + func forgetSessionAccess() async throws { + try await withStateRevisionTracking { sdk in + _ = try await sdk.forgetSessionAccess() } + resetRuntime() } func clearState() async { @@ -1024,7 +1006,10 @@ actor PaykitSdkService { } private func bootstrap() throws -> PubkySessionBootstrap { - try PubkySessionBootstrap.withPubkyClientConfig(pubkyClient: pubkyClientConfig) + try PubkySessionBootstrap.withPubkyClientConfig( + clientId: Self.clientID, + pubkyClient: pubkyClientConfig + ) } nonisolated static func makePubkyClientConfig(localTestnetHost: String?) -> PubkyClientConfig { @@ -1035,15 +1020,19 @@ actor PaykitSdkService { private nonisolated static func config() throws -> PaykitSdkConfig { var config = try Paykit.defaultConfig(receiverPath: PaykitReceiverPath.wallet) - config.profileNamespace = switch Env.network { - case .bitcoin: "bitkit.to" - default: "staging.bitkit.to" - } + config.profileNamespace = clientID config.endpointManagementScope = .managedOnly config.encryptedLinkRecoveryMarkers = .enabled config.publicContactSharing = .localOnly return config } + + nonisolated static var clientID: String { + switch Env.network { + case .bitcoin: "bitkit.to" + default: "staging.bitkit.to" + } + } } private final class PaykitSdkOperationLock: @unchecked Sendable { @@ -1165,6 +1154,7 @@ private final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecke } return try PubkySessionAccess( + clientId: PaykitSdkService.clientID, sessionSecret: sessionSecret, localSecretKey: loadLocalSecretKey(), receiverNoiseSecretKey: loadOrDeriveReceiverNoiseSecretKey() @@ -1195,8 +1185,8 @@ private final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecke func clearSessionAccess() throws { clearLiveSessionAccess() - try? Keychain.delete(key: .paykitSession) - try? Keychain.delete(key: .pubkySecretKey) + try Keychain.delete(key: .pubkySecretKey) + try Keychain.delete(key: .paykitSession) } func loadLocalSecretKey() throws -> PubkyLocalSecretKey? { diff --git a/BitkitTests/PaykitSdkClientConfigTests.swift b/BitkitTests/PaykitSdkClientConfigTests.swift index c1cca72c7..9691c1106 100644 --- a/BitkitTests/PaykitSdkClientConfigTests.swift +++ b/BitkitTests/PaykitSdkClientConfigTests.swift @@ -3,6 +3,12 @@ import Paykit import XCTest final class PaykitSdkClientConfigTests: XCTestCase { + func testClientIDUsesBitkitOwnedDomain() { + let expectedClientID = Env.network == .bitcoin ? "bitkit.to" : "staging.bitkit.to" + + XCTAssertEqual(PaykitSdkService.clientID, expectedClientID) + } + func testProductionUsesDefaultPubkyClient() { let config = PaykitSdkService.makePubkyClientConfig(localTestnetHost: nil) diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index 43be620ac..1c1a1315f 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -115,10 +115,10 @@ final class PubkyProfileManagerTests: XCTestCase { } @MainActor - func testCompleteAuthenticationClearsSessionWhenAuthIsCanceledAfterCompletion() async { + func testCompleteAuthenticationRevokesSessionWhenAuthIsCanceledAfterCompletion() async { let manager = PubkyProfileManager() let attemptID = UUID() - var didClearSession = false + var didRevokeSession = false manager.setActiveAuthAttemptIDForTesting(attemptID) manager.authState = .authenticating @@ -131,13 +131,13 @@ final class PubkyProfileManagerTests: XCTestCase { currentPublicKey: { "pubky_test" }, - clearSessionAccess: { - didClearSession = true + revokeSessionAccess: { + didRevokeSession = true } ) XCTFail("Expected cancellation") } catch is CancellationError { - XCTAssertTrue(didClearSession) + XCTAssertTrue(didRevokeSession) XCTAssertNil(manager.activeAuthAttemptIDForTesting) } catch { XCTFail("Expected CancellationError, got \(error)") @@ -436,7 +436,7 @@ final class PubkyProfileManagerTests: XCTestCase { deleteKeychainValue: { key in store.removeValue(forKey: key.storageKey) }, - clearSessionAccess: { + forgetSessionAccess: { didClearSessionAccess = true }, signInWithSecretKey: { _ in @@ -473,7 +473,7 @@ final class PubkyProfileManagerTests: XCTestCase { deleteKeychainValue: { key in store.removeValue(forKey: key.storageKey) }, - clearSessionAccess: {}, + forgetSessionAccess: {}, signInWithSecretKey: { _ in XCTFail("Missing pubky state should not sign in") return "unused-session" @@ -508,7 +508,7 @@ final class PubkyProfileManagerTests: XCTestCase { deleteKeychainValue: { key in store.removeValue(forKey: key.storageKey) }, - clearSessionAccess: {}, + forgetSessionAccess: {}, signInWithSecretKey: { secretKey in XCTAssertFalse(secretKey.isEmpty) store[KeychainEntryType.pubkySecretKey.storageKey] = secretKey @@ -542,7 +542,7 @@ final class PubkyProfileManagerTests: XCTestCase { deleteKeychainValue: { key in store.removeValue(forKey: key.storageKey) }, - clearSessionAccess: {}, + forgetSessionAccess: {}, signInWithSecretKey: { _ in throw PubkyServiceError.authFailed("offline") } diff --git a/changelog.d/next/paykit-rc50.security.md b/changelog.d/next/paykit-rc50.security.md new file mode 100644 index 000000000..5ab4d364f --- /dev/null +++ b/changelog.d/next/paykit-rc50.security.md @@ -0,0 +1 @@ +Updated Pubky authentication to use app-scoped grants with secure sign-out. From c1a03c466a8e6c3fc6a9cf8cc988758ddf9cb62c Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 31 Aug 2026 14:05:23 -0500 Subject: [PATCH 02/40] chore: rename changelog fragment --- changelog.d/next/{paykit-rc50.security.md => 697.security.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{paykit-rc50.security.md => 697.security.md} (100%) diff --git a/changelog.d/next/paykit-rc50.security.md b/changelog.d/next/697.security.md similarity index 100% rename from changelog.d/next/paykit-rc50.security.md rename to changelog.d/next/697.security.md From 586fce5e11c142699aa49933bd0fecf07f894235 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 31 Aug 2026 14:23:35 -0500 Subject: [PATCH 03/40] fix: reconcile paykit auth failures --- Bitkit/AppScene.swift | 17 ++++++-- Bitkit/Managers/PubkyProfileManager.swift | 41 +++++++++++++++---- .../PrivatePaykitService+Contacts.swift | 18 +++++++- Bitkit/Services/PublicPaykitService.swift | 16 ++++++++ BitkitTests/PubkyProfileManagerTests.swift | 16 ++++++++ BitkitTests/PublicPaykitServiceTests.swift | 12 ++++++ 6 files changed, 107 insertions(+), 13 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index ab8f05a7f..cfd2ac316 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -933,11 +933,20 @@ struct AppScene: View { private func retryPendingPaykitEndpointRemoval() async { if PublicPaykitService.isCleanupPending { do { - if UserDefaults.standard.bool(forKey: PublicPaykitService.publishingEnabledKey) { + switch PublicPaykitService.pendingReconciliationMode() { + case .publishEndpoints: try await PublicPaykitService.syncCurrentPublishedEndpoints(wallet: wallet) - } else { + case .publishReceiverMarker: + try await PublicPaykitService.syncLocalReceiverMarker( + publicSharingEnabled: false, + privateSharingEnabled: true + ) + case .removePublishedState: try await PublicPaykitService.removePublishedEndpoints() - try await PublicPaykitService.syncLocalReceiverMarker(publicSharingEnabled: false) + try await PublicPaykitService.syncLocalReceiverMarker( + publicSharingEnabled: false, + privateSharingEnabled: false + ) } PublicPaykitService.setCleanupPending(false) } catch { @@ -945,7 +954,7 @@ struct AppScene: View { } } - await PrivatePaykitService.shared.retryPendingEndpointRemoval( + await PrivatePaykitService.shared.retryPendingEndpointReconciliation( wallet: wallet, savedPublicKeys: contactsManager.contacts.map(\.publicKey) ) diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index acc607749..fe8ea490d 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -769,18 +769,43 @@ class PubkyProfileManager: ObservableObject { } private func signOut(cleanPrivatePaykitEndpoints: Bool) async throws { - try await Task.detached { - if cleanPrivatePaykitEndpoints { - try await Self.removePrivatePaykitEndpoints(context: "PubkyProfileManager.signOut") - } - await Self.removePublicPaykitEndpointsBestEffort(context: "PubkyProfileManager.signOut") - try await PubkyService.signOut() - await Self.clearLocalAppState() - }.value + let publicSharingEnabled = UserDefaults.standard.bool(forKey: PublicPaykitService.publishingEnabledKey) + let privateSharingEnabled = UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey) + + do { + try await Task.detached { + if cleanPrivatePaykitEndpoints { + try await Self.removePrivatePaykitEndpoints(context: "PubkyProfileManager.signOut") + } + await Self.removePublicPaykitEndpointsBestEffort(context: "PubkyProfileManager.signOut") + try await PubkyService.signOut() + await Self.clearLocalAppState() + }.value + } catch { + Self.markPaykitReconciliationPendingAfterFailedSignOut( + publicSharingEnabled: publicSharingEnabled, + privateSharingEnabled: privateSharingEnabled + ) + throw error + } clearAuthenticatedState() } + static func markPaykitReconciliationPendingAfterFailedSignOut( + publicSharingEnabled: Bool, + privateSharingEnabled: Bool, + setPublicReconciliationPending: (Bool) -> Void = PublicPaykitService.setCleanupPending, + setPrivateReconciliationPending: (Bool) -> Void = PrivatePaykitService.setContactSharingCleanupPending + ) { + if publicSharingEnabled || privateSharingEnabled { + setPublicReconciliationPending(true) + } + if privateSharingEnabled { + setPrivateReconciliationPending(true) + } + } + func refreshSessionIfPossible(after error: Error) async -> Bool { await Self.refreshSessionIfPossible( after: error, diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index 0e0c2d151..bd0484c2f 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -255,9 +255,25 @@ extension PrivatePaykitService { } } - func retryPendingEndpointRemoval(wallet _: WalletViewModel, savedPublicKeys publicKeys: [String]) async { + func retryPendingEndpointReconciliation(wallet: WalletViewModel, savedPublicKeys publicKeys: [String]) async { let savedKeys = Set(normalizedSavedContactKeys(publicKeys)) let isFullCleanupPending = UserDefaults.standard.bool(forKey: Self.cleanupPendingKey) + if isFullCleanupPending, + UserDefaults.standard.bool(forKey: Self.publishingEnabledKey) + { + let error = await prepareSavedContacts( + Array(savedKeys), + wallet: wallet, + requireImmediatePublication: true + ) + if let error { + Logger.warn("Failed to reconcile private Paykit endpoints: \(error)", context: "PrivatePaykit") + } else { + Self.setContactSharingCleanupPending(false) + } + return + } + let cleanupKeys = isFullCleanupPending ? Set(knownSavedContactKeys).union(state.contacts.keys).union(Self.pendingDeletedContactCleanupKeys()) : Set(pendingPrivateEndpointRemovalKeys(savedPublicKeys: publicKeys)) diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift index 72274ba5b..83ab7015d 100644 --- a/Bitkit/Services/PublicPaykitService.swift +++ b/Bitkit/Services/PublicPaykitService.swift @@ -95,6 +95,22 @@ enum PublicPaykitService { UserDefaults.standard.bool(forKey: cleanupPendingKey) } + enum PendingReconciliationMode: Equatable { + case publishEndpoints + case publishReceiverMarker + case removePublishedState + } + + static func pendingReconciliationMode(defaults: UserDefaults = .standard) -> PendingReconciliationMode { + if defaults.bool(forKey: publishingEnabledKey) { + return .publishEndpoints + } + if defaults.bool(forKey: PrivatePaykitService.publishingEnabledKey) { + return .publishReceiverMarker + } + return .removePublishedState + } + enum MethodId: String, Hashable, CaseIterable { case bitcoinLightningBolt11 = "btc-lightning-bolt11" case bitcoinLightningLnurl = "btc-lightning-lnurl" diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index 1c1a1315f..881cfffe3 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -114,6 +114,22 @@ final class PubkyProfileManagerTests: XCTestCase { XCTAssertFalse(manager.isAuthenticated) } + @MainActor + func testFailedSignOutMarksEnabledPaykitStateForReconciliation() { + var publicPending = false + var privatePending = false + + PubkyProfileManager.markPaykitReconciliationPendingAfterFailedSignOut( + publicSharingEnabled: false, + privateSharingEnabled: true, + setPublicReconciliationPending: { publicPending = $0 }, + setPrivateReconciliationPending: { privatePending = $0 } + ) + + XCTAssertTrue(publicPending) + XCTAssertTrue(privatePending) + } + @MainActor func testCompleteAuthenticationRevokesSessionWhenAuthIsCanceledAfterCompletion() async { let manager = PubkyProfileManager() diff --git a/BitkitTests/PublicPaykitServiceTests.swift b/BitkitTests/PublicPaykitServiceTests.swift index 94c525e53..c1602ae1e 100644 --- a/BitkitTests/PublicPaykitServiceTests.swift +++ b/BitkitTests/PublicPaykitServiceTests.swift @@ -187,6 +187,18 @@ final class PublicPaykitServiceTests: XCTestCase { } } + func testPendingReconciliationRestoresPrivateOnlyReceiverMarker() throws { + try withIsolatedDefaults { defaults in + defaults.set(false, forKey: PublicPaykitService.publishingEnabledKey) + defaults.set(true, forKey: PrivatePaykitService.publishingEnabledKey) + + XCTAssertEqual( + PublicPaykitService.pendingReconciliationMode(defaults: defaults), + .publishReceiverMarker + ) + } + } + private func endpoint(_ methodId: PublicPaykitService.MethodId, value: String) -> PublicPaykitService.Endpoint { PublicPaykitService.Endpoint( methodId: methodId, From f32b56f55e694871681f04ce622e08970cebb445 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 1 Sep 2026 07:29:59 -0500 Subject: [PATCH 04/40] test: cover private endpoint reconciliation --- .../PrivatePaykitService+Contacts.swift | 11 ++++++- BitkitTests/PrivatePaykitServiceTests.swift | 32 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index bd0484c2f..e81d23d43 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -4,6 +4,15 @@ import Paykit // MARK: - Saved Contacts extension PrivatePaykitService { + enum FullCleanupReconciliationMode: Equatable { + case restoreSavedContacts + case removePublishedState + } + + static func fullCleanupReconciliationMode(defaults: UserDefaults = .standard) -> FullCleanupReconciliationMode { + return defaults.bool(forKey: publishingEnabledKey) ? .restoreSavedContacts : .removePublishedState + } + @discardableResult func prepareSavedContacts( _ publicKeys: [String], @@ -259,7 +268,7 @@ extension PrivatePaykitService { let savedKeys = Set(normalizedSavedContactKeys(publicKeys)) let isFullCleanupPending = UserDefaults.standard.bool(forKey: Self.cleanupPendingKey) if isFullCleanupPending, - UserDefaults.standard.bool(forKey: Self.publishingEnabledKey) + Self.fullCleanupReconciliationMode() == .restoreSavedContacts { let error = await prepareSavedContacts( Array(savedKeys), diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index fb8407358..48c46e0a0 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -17,6 +17,30 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertTrue(PrivatePaykitService.initialLinkBurstRetryDelays.allSatisfy { $0 == 2_000_000_000 }) } + func testPendingEndpointReconciliationRestoresSavedContactsWhenPublishingRemainsEnabled() throws { + try withIsolatedDefaults { defaults in + defaults.set(true, forKey: PrivatePaykitService.cleanupPendingKey) + defaults.set(true, forKey: PrivatePaykitService.publishingEnabledKey) + + XCTAssertEqual( + PrivatePaykitService.fullCleanupReconciliationMode(defaults: defaults), + .restoreSavedContacts + ) + } + } + + func testPendingEndpointReconciliationRemovesPublishedStateWhenPublishingIsDisabled() throws { + try withIsolatedDefaults { defaults in + defaults.set(true, forKey: PrivatePaykitService.cleanupPendingKey) + defaults.set(false, forKey: PrivatePaykitService.publishingEnabledKey) + + XCTAssertEqual( + PrivatePaykitService.fullCleanupReconciliationMode(defaults: defaults), + .removePublishedState + ) + } + } + func testReceiverNoiseDerivationMatchesCrossPlatformVector() { let seed = ( "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e534955" + @@ -32,6 +56,14 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertEqual(key.hex, "500f4799bbb2d02103e3b74b365ddb478a3187333c053fa9eb62f4052ba6a327") } + private func withIsolatedDefaults(_ body: (UserDefaults) throws -> Void) throws { + let suiteName = "PrivatePaykitServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + try body(defaults) + } + func testDuplicatePaymentErrorClassificationUsesWrappedAppErrorReason() { XCTAssertTrue( PrivatePaykitService.isDuplicatePaymentError( From b62fedf98fad4097cb5d755a9c38b4b88e35e6e0 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 1 Sep 2026 11:46:24 -0500 Subject: [PATCH 05/40] test: use grant auth fixtures --- BitkitTests/PubkyAuthApprovalSheetTests.swift | 14 +++++++++++--- BitkitTests/PubkyAuthRequestTests.swift | 13 ++++++------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/BitkitTests/PubkyAuthApprovalSheetTests.swift b/BitkitTests/PubkyAuthApprovalSheetTests.swift index cc1d17dc9..c07bc186e 100644 --- a/BitkitTests/PubkyAuthApprovalSheetTests.swift +++ b/BitkitTests/PubkyAuthApprovalSheetTests.swift @@ -6,13 +6,21 @@ import XCTest private let approvalTestXpub = "tpubDDWohsp5dx2iMJ9N7iHbgAEDhH4BJB9NWW1fEW3yA3AFNDREmpzteCXNqppMLUmKFY5q5e3" + "PXtS5CuqWCQbYcGhpPqYAgQSYdwknW9J6sQv" +private let approvalTestClientPublicKey = "5jsjx1o6fzu6aeeo697r3i5rx15zq41kikcye8wtwdqm4nb4tryo" private func approvalTestAuthUrl(secret: String = "e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s") -> String { - "pubkyauth://signin?caps=\(PubkyAuthClaim.watchOnlyAccountCapabilities)" + + "pubkyauth://signin_grant?caps=\(PubkyAuthClaim.watchOnlyAccountCapabilities)" + "&relay=https://httprelay.pubky.app/inbox/&secret=\(secret)" + + "&cid=paykit.test&cpk=\(approvalTestClientPublicKey)" + "&x-bitkit-claim=watch-only-account-v1" } +private func ordinaryApprovalTestAuthUrl() -> String { + "pubkyauth://signin_grant?caps=/pub/example/:rw&relay=https://httprelay.pubky.app/inbox/" + + "&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s" + + "&cid=paykit.test&cpk=\(approvalTestClientPublicKey)" +} + final class PubkyAuthApprovalSheetTests: XCTestCase { func testAuthDisplayPublicKeyOmitsPubkyPrefix() { XCTAssertEqual(pubkyAuthDisplayPublicKey("pubky3rsd123456789w5xg"), "3rsd...w5xg") @@ -33,7 +41,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { } func testOrdinaryRequestStartsAtNormalAuthorization() throws { - let authUrl = "pubkyauth://signin?caps=/pub/example/:rw&relay=https://httprelay.pubky.app/inbox/&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s" + let authUrl = ordinaryApprovalTestAuthUrl() let request = try PubkyAuthRequest.parse(url: authUrl) XCTAssertEqual(PubkyAuthApprovalSheet.initialState(for: request), .authorize) @@ -41,7 +49,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { @MainActor func testOrdinaryRequestUsesOrdinaryApproval() async throws { - let authUrl = "pubkyauth://signin?caps=/pub/example/:rw&relay=https://httprelay.pubky.app/inbox/&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s" + let authUrl = ordinaryApprovalTestAuthUrl() let request = try PubkyAuthRequest.parse(url: authUrl) var approvedCapabilities: String? diff --git a/BitkitTests/PubkyAuthRequestTests.swift b/BitkitTests/PubkyAuthRequestTests.swift index b1b4af240..56403be41 100644 --- a/BitkitTests/PubkyAuthRequestTests.swift +++ b/BitkitTests/PubkyAuthRequestTests.swift @@ -5,6 +5,7 @@ import XCTest final class PubkyAuthRequestTests: XCTestCase { private let relay = "https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F" private let secret = "e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s" + private let clientPublicKey = "5jsjx1o6fzu6aeeo697r3i5rx15zq41kikcye8wtwdqm4nb4tryo" func testProtocolUrlRecognizesPubkyAuthSchemeCaseInsensitively() { XCTAssertTrue(PubkyAuthRequest.isProtocolURL("pubkyauth://signin?caps=/pub/bitkit.to/:rw")) @@ -15,7 +16,7 @@ final class PubkyAuthRequestTests: XCTestCase { func testParseUrlPreservesRequestedCapabilities() throws { let capabilities = "/pub/bitkit.to/:rw" - let url = "pubkyauth://signin?caps=\(capabilities)&relay=https://httprelay.pubky.app/inbox/&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s" + let url = authUrl(capabilities: capabilities) let request = try PubkyAuthRequest.parse(url: url) @@ -63,13 +64,10 @@ final class PubkyAuthRequestTests: XCTestCase { XCTAssertEqual(request.bitkitClaim, .watchOnlyAccountV1) } - func testParseUrlRecognizesWatchOnlyAccountClaimWithCapabilityWhitespace() throws { + func testWatchOnlyCapabilityMatcherAllowsWhitespace() { let capabilities = PubkyAuthClaim.watchOnlyAccountCapabilities.replacingOccurrences(of: ",", with: " , ") - let url = authUrl(capabilities: capabilities, claimValues: [PubkyAuthClaim.watchOnlyAccountV1.rawValue]) - - let request = try PubkyAuthRequest.parse(url: url) - XCTAssertEqual(request.bitkitClaim, .watchOnlyAccountV1) + XCTAssertTrue(PubkyAuthClaim.matchesWatchOnlyAccountCapabilities(capabilities)) } func testParseUrlWithoutBitkitClaimPreservesNormalAuth() throws { @@ -262,6 +260,7 @@ final class PubkyAuthRequestTests: XCTestCase { let claims = claimValues .map { "&\(PubkyAuthClaim.queryParameter)=\($0)" } .joined() - return "pubkyauth://signin?caps=\(capabilities)&relay=\(relay)&secret=\(secret)\(claims)" + return "pubkyauth://signin_grant?caps=\(capabilities)&relay=\(relay)&secret=\(secret)" + + "&cid=paykit.test&cpk=\(clientPublicKey)\(claims)" } } From 0e0725a829634a9cc3637d995b2440d5dd131605 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 2 Sep 2026 13:03:36 -0500 Subject: [PATCH 06/40] fix: approve external Pubky grants --- Bitkit/Models/PubkyAuthRequest.swift | 2 + .../Localization/en.lproj/Localizable.strings | 1 + Bitkit/Services/PubkyService.swift | 46 ++++++++++++++----- .../PubkyAuthApprovalSheet.swift | 3 ++ BitkitTests/PubkyAuthApprovalSheetTests.swift | 35 ++++++++------ BitkitTests/PubkyAuthRequestTests.swift | 1 + changelog.d/next/697.security.md | 2 +- 7 files changed, 63 insertions(+), 27 deletions(-) diff --git a/Bitkit/Models/PubkyAuthRequest.swift b/Bitkit/Models/PubkyAuthRequest.swift index 8b06f15c1..4a785ca16 100644 --- a/Bitkit/Models/PubkyAuthRequest.swift +++ b/Bitkit/Models/PubkyAuthRequest.swift @@ -53,6 +53,7 @@ struct PubkyAuthPermission { struct PubkyAuthRequest { let rawUrl: String let kind: Paykit.PubkyAuthRequestKind + let clientID: String let relay: String let capabilities: String let permissions: [PubkyAuthPermission] @@ -75,6 +76,7 @@ struct PubkyAuthRequest { return PubkyAuthRequest( rawUrl: url, kind: details.kind, + clientID: details.clientId, relay: details.relayUrl ?? "", capabilities: capabilities, permissions: permissions, diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 634feaa4b..77d3e1133 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -693,6 +693,7 @@ "pubky_auth__title" = "Authorize"; "pubky_auth__description_prefix" = "A service is requesting permission to access and edit your "; "pubky_auth__description_suffix" = " data."; +"pubky_auth__requester" = "Requested by {clientId}"; "pubky_auth__requested_permissions" = "REQUESTED PERMISSIONS"; "pubky_auth__watch_only_account_default_name" = "{service} account"; "pubky_auth__watch_only_account_fallback_name" = "Paykit server account"; diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 458ff0a76..d0ee899c7 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -89,18 +89,25 @@ enum PubkyService { } /// Approve a pubkyauth:// request using the local secret key. - static func approveAuth(authUrl: String, expectedCapabilities: String, secretKeyHex: String) async throws { + static func approveAuth(authUrl: String, expectedCapabilities: String, approvedClientID: String, secretKeyHex: String) async throws { try await PaykitSdkService.shared.approveAuth( authUrl: authUrl, expectedCapabilities: expectedCapabilities, + approvedClientID: approvedClientID, secretKeyHex: secretKeyHex ) } - static func approveAuthWithCompanionClaim(authUrl: String, unsignedPayload: Data, secretKeyHex: String) async throws { + static func approveAuthWithCompanionClaim( + authUrl: String, + approvedClientID: String, + unsignedPayload: Data, + secretKeyHex: String + ) async throws { try await PaykitSdkService.shared.approveAuthWithCompanionClaim( authUrl: authUrl, expectedCapabilities: PubkyAuthClaim.watchOnlyAccountCapabilities, + approvedClientID: approvedClientID, secretKeyHex: secretKeyHex, claim: Paykit.PubkyAuthCompanionClaim( queryParameter: PubkyAuthClaim.queryParameter, @@ -119,8 +126,8 @@ enum PubkyService { return false } - typealias OrdinaryAuthApproval = (String, String, String) async throws -> Void - typealias CompanionAuthApproval = (String, Data, String) async throws -> Void + typealias OrdinaryAuthApproval = (String, String, String, String) async throws -> Void + typealias CompanionAuthApproval = (String, String, Data, String) async throws -> Void @MainActor static func approveAuthRequest( @@ -129,16 +136,18 @@ enum PubkyService { accountName: String, secretKeyHex: String, accountManager: WatchOnlyAccountManager? = nil, - ordinaryApproval: @escaping OrdinaryAuthApproval = { authUrl, capabilities, secretKeyHex in + ordinaryApproval: @escaping OrdinaryAuthApproval = { authUrl, capabilities, clientID, secretKeyHex in try await approveAuth( authUrl: authUrl, expectedCapabilities: capabilities, + approvedClientID: clientID, secretKeyHex: secretKeyHex ) }, - companionApproval: @escaping CompanionAuthApproval = { authUrl, unsignedPayload, secretKeyHex in + companionApproval: @escaping CompanionAuthApproval = { authUrl, clientID, unsignedPayload, secretKeyHex in try await approveAuthWithCompanionClaim( authUrl: authUrl, + approvedClientID: clientID, unsignedPayload: unsignedPayload, secretKeyHex: secretKeyHex ) @@ -161,7 +170,7 @@ enum PubkyService { } do { - try await companionApproval(authUrl, preparedClaim.1, secretKeyHex) + try await companionApproval(authUrl, request.clientID, preparedClaim.1, secretKeyHex) } catch { if !didDeliverCompanionClaim(error: error) { await cancelIncompleteAuthorization( @@ -174,7 +183,7 @@ enum PubkyService { try await accountManager.markSetupActive(attempt: authorizationAttempt) } else { - try await ordinaryApproval(authUrl, request.capabilities, secretKeyHex) + try await ordinaryApproval(authUrl, request.capabilities, request.clientID, secretKeyHex) } } @@ -441,9 +450,9 @@ actor PaykitSdkService { activeAuthRequestID = nil } - func approveAuth(authUrl: String, expectedCapabilities: String, secretKeyHex: String) async throws { + func approveAuth(authUrl: String, expectedCapabilities: String, approvedClientID: String, secretKeyHex: String) async throws { try await operationLock.withLock { - try await bootstrap().approveAuth( + try await approvalBootstrap(authUrl: authUrl, approvedClientID: approvedClientID).approveAuth( authUrl: authUrl, expectedCapabilities: expectedCapabilities, localSecretKey: Self.localSecretKey(fromHex: secretKeyHex) @@ -454,11 +463,12 @@ actor PaykitSdkService { func approveAuthWithCompanionClaim( authUrl: String, expectedCapabilities: String, + approvedClientID: String, secretKeyHex: String, claim: Paykit.PubkyAuthCompanionClaim ) async throws { try await operationLock.withLock { - try await bootstrap().approveAuthWithCompanionClaim( + try await approvalBootstrap(authUrl: authUrl, approvedClientID: approvedClientID).approveAuthWithCompanionClaim( authUrl: authUrl, expectedCapabilities: expectedCapabilities, localSecretKey: Self.localSecretKey(fromHex: secretKeyHex), @@ -1012,6 +1022,20 @@ actor PaykitSdkService { ) } + private func approvalBootstrap(authUrl: String, approvedClientID: String) throws -> PubkySessionBootstrap { + let requestClientID = try Paykit.parsePubkyAuthUrl(authUrl: authUrl).clientId + guard !approvedClientID.isEmpty, approvedClientID == requestClientID else { + throw AppError( + message: "pubky_auth__invalid_request", + debugMessage: "Approved Pubky client ID does not match auth request" + ) + } + return try PubkySessionBootstrap.withPubkyClientConfig( + clientId: approvedClientID, + pubkyClient: pubkyClientConfig + ) + } + nonisolated static func makePubkyClientConfig(localTestnetHost: String?) -> PubkyClientConfig { var config = Paykit.defaultPubkyClientConfig() config.localTestnetHost = localTestnetHost diff --git a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift index 2d79b2076..df1aae581 100644 --- a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift +++ b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift @@ -229,6 +229,9 @@ struct PubkyAuthApprovalSheet: View { ScrollView { VStack(alignment: .leading, spacing: 0) { descriptionText + .padding(.bottom, 8) + + BodySText(t("pubky_auth__requester", variables: ["clientId": config.request.clientID])) .padding(.bottom, 32) permissionsSection diff --git a/BitkitTests/PubkyAuthApprovalSheetTests.swift b/BitkitTests/PubkyAuthApprovalSheetTests.swift index c07bc186e..f1d6b83e5 100644 --- a/BitkitTests/PubkyAuthApprovalSheetTests.swift +++ b/BitkitTests/PubkyAuthApprovalSheetTests.swift @@ -52,17 +52,22 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { let authUrl = ordinaryApprovalTestAuthUrl() let request = try PubkyAuthRequest.parse(url: authUrl) var approvedCapabilities: String? + var approvedClientID: String? try await PubkyService.approveAuthRequest( request: request, authUrl: authUrl, accountName: "", secretKeyHex: "secret", - ordinaryApproval: { _, capabilities, _ in approvedCapabilities = capabilities }, - companionApproval: { _, _, _ in XCTFail("Ordinary auth must not deliver a companion claim") } + ordinaryApproval: { _, capabilities, clientID, _ in + approvedCapabilities = capabilities + approvedClientID = clientID + }, + companionApproval: { _, _, _, _ in XCTFail("Ordinary auth must not deliver a companion claim") } ) XCTAssertEqual(approvedCapabilities, "/pub/example/:rw") + XCTAssertEqual(approvedClientID, "paykit.test") } func testResolvePubkyApprovalLocalAuthModePrefersPinWhenPinEnabled() { @@ -125,8 +130,8 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { accountName: "Creator store", secretKeyHex: "secret", accountManager: manager, - ordinaryApproval: { _, _, _ in ordinaryApprovalCount += 1 }, - companionApproval: { _, _, _ in + ordinaryApproval: { _, _, _, _ in ordinaryApprovalCount += 1 }, + companionApproval: { _, _, _, _ in companionApprovalCount += 1 throw ApprovalFakeError.deliveryFailed } @@ -159,7 +164,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { accountName: "Creator store", secretKeyHex: "secret", accountManager: manager, - companionApproval: { _, _, _ in } + companionApproval: { _, _, _, _ in } ) XCTAssertEqual(manager.accounts.first?.setupState, .active) @@ -196,7 +201,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { accountName: "First account", secretKeyHex: "secret", accountManager: manager, - companionApproval: { _, _, _ in await companionApprovalGate.approve() } + companionApproval: { _, _, _, _ in await companionApprovalGate.approve() } ) } try await companionApprovalGate.waitUntilFirstApprovalStarts() @@ -209,7 +214,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { accountName: "Replacement account", secretKeyHex: "secret", accountManager: manager, - companionApproval: { _, _, _ in XCTFail("Concurrent companion approval must not start") } + companionApproval: { _, _, _, _ in XCTFail("Concurrent companion approval must not start") } ) XCTFail("Expected concurrent authorization to be rejected") } catch { @@ -230,7 +235,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { accountName: "Second account", secretKeyHex: "secret", accountManager: manager, - companionApproval: { _, _, _ in } + companionApproval: { _, _, _, _ in } ) XCTAssertEqual(manager.accounts.map(\.setupState), [.active, .active]) @@ -255,7 +260,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { accountName: "Creator store", secretKeyHex: "secret", accountManager: manager, - companionApproval: { _, _, _ in + companionApproval: { _, _, _, _ in throw Paykit.PubkyAuthCompanionClaimApprovalError.AuthorizationFailure(reason: "normal auth failed") } ) @@ -284,7 +289,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { accountName: "Creator store", secretKeyHex: "secret", accountManager: manager, - companionApproval: { _, _, _ in + companionApproval: { _, _, _, _ in throw Paykit.PubkyAuthCompanionClaimApprovalError.AuthorizationFailure(reason: "normal auth failed") } ) @@ -297,7 +302,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { accountName: "Creator store", secretKeyHex: "secret", accountManager: manager, - companionApproval: { _, _, _ in throw ApprovalFakeError.deliveryFailed } + companionApproval: { _, _, _, _ in throw ApprovalFakeError.deliveryFailed } ) } @@ -325,7 +330,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { accountName: "Creator store", secretKeyHex: "secret", accountManager: initialManager, - companionApproval: { _, payload, _ in + companionApproval: { _, _, payload, _ in deliveredPayloads.append(payload) throw Paykit.PubkyAuthCompanionClaimApprovalError.AuthorizationFailure(reason: "normal auth failed") } @@ -343,7 +348,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { accountName: "Creator store", secretKeyHex: "secret", accountManager: restartedManager, - companionApproval: { _, payload, _ in deliveredPayloads.append(payload) } + companionApproval: { _, _, payload, _ in deliveredPayloads.append(payload) } ) let activeAccount = try XCTUnwrap(restartedManager.accounts.first) @@ -377,7 +382,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { accountName: "Creator store", secretKeyHex: "secret", accountManager: manager, - companionApproval: { _, _, _ in companionApprovalCount += 1 } + companionApproval: { _, _, _, _ in companionApprovalCount += 1 } ) } @@ -407,7 +412,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { accountName: "Creator store", secretKeyHex: "secret", accountManager: manager, - companionApproval: { _, _, _ in + companionApproval: { _, _, _, _ in await companionApprovalGate.approve() try Task.checkCancellation() } diff --git a/BitkitTests/PubkyAuthRequestTests.swift b/BitkitTests/PubkyAuthRequestTests.swift index 56403be41..cb4cb80a2 100644 --- a/BitkitTests/PubkyAuthRequestTests.swift +++ b/BitkitTests/PubkyAuthRequestTests.swift @@ -20,6 +20,7 @@ final class PubkyAuthRequestTests: XCTestCase { let request = try PubkyAuthRequest.parse(url: url) + XCTAssertEqual(request.clientID, "paykit.test") XCTAssertEqual(request.capabilities, capabilities) XCTAssertEqual(request.permissions.count, 1) XCTAssertEqual(request.permissions[0].path, "/pub/bitkit.to/") diff --git a/changelog.d/next/697.security.md b/changelog.d/next/697.security.md index 5ab4d364f..bab344490 100644 --- a/changelog.d/next/697.security.md +++ b/changelog.d/next/697.security.md @@ -1 +1 @@ -Updated Pubky authentication to use app-scoped grants with secure sign-out. +Updated Pubky authentication to use app-scoped grants, authorize external services, and securely sign out. From 4befe20f54603164c37ad92b7a1d6f4575f1e820 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 2 Sep 2026 14:09:25 -0500 Subject: [PATCH 07/40] fix: harden Pubky session recovery --- Bitkit/Services/PubkyService.swift | 4 +++- BitkitTests/PaykitSdkClientConfigTests.swift | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index d0ee899c7..538524607 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -825,6 +825,8 @@ actor PaykitSdkService { func forgetSessionAccess() async throws { try await withStateRevisionTracking { sdk in + activeAuthRequest = nil + activeAuthRequestID = nil _ = try await sdk.forgetSessionAccess() } resetRuntime() @@ -1008,7 +1010,7 @@ actor PaykitSdkService { return false } - return context == "import Pubky session from platform provider" + return context == "restore Pubky grant session from platform provider" } private nonisolated static func canReceivePrivatePaymentDetails(marker: Paykit.PaykitReceiverMarker?) -> Bool { diff --git a/BitkitTests/PaykitSdkClientConfigTests.swift b/BitkitTests/PaykitSdkClientConfigTests.swift index 9691c1106..d344317aa 100644 --- a/BitkitTests/PaykitSdkClientConfigTests.swift +++ b/BitkitTests/PaykitSdkClientConfigTests.swift @@ -22,13 +22,13 @@ final class PaykitSdkClientConfigTests: XCTestCase { } func testStoredSessionCanBeDeferredDuringSdkInitialization() { - let error = PaykitError.Identity(code: "identity_error", context: "import Pubky session from platform provider") + let error = PaykitError.Identity(code: "identity_error", context: "restore Pubky grant session from platform provider") XCTAssertTrue(PaykitSdkService.shouldDeferStaleSession(error: error, hasStoredSession: true)) } func testMissingSessionOrUnrelatedIdentityFailureIsNotDeferred() { - let staleSession = PaykitError.Identity(code: "identity_error", context: "import Pubky session from platform provider") + let staleSession = PaykitError.Identity(code: "identity_error", context: "restore Pubky grant session from platform provider") let unrelatedError = PaykitError.Identity(code: "identity_error", context: "local Pubky secret key does not match session public key") XCTAssertFalse(PaykitSdkService.shouldDeferStaleSession(error: staleSession, hasStoredSession: false)) From d3836465074df508776c4abbd729f0d7478fa8d1 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 2 Sep 2026 15:50:53 -0500 Subject: [PATCH 08/40] test: cover external auth client ID --- Bitkit/Services/PubkyService.swift | 16 ++++++--- BitkitTests/PaykitSdkClientConfigTests.swift | 38 ++++++++++++++++++++ 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 538524607..6f9b51ae7 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -291,6 +291,8 @@ enum PubkyService { // MARK: - Paykit SDK Runtime actor PaykitSdkService { + typealias ApprovalBootstrapFactory = (String, PubkyClientConfig) throws -> PubkySessionBootstrap + static let shared = PaykitSdkService() private static let walletBackupDataChangedSubject = PassthroughSubject() @@ -303,10 +305,17 @@ actor PaykitSdkService { private let paymentAdapter = PaykitSdkPaymentAdapter() private let operationLock = PaykitSdkOperationLock() private let pubkyClientConfig = PaykitSdkService.makePubkyClientConfig(localTestnetHost: Env.pubkyLocalTestnetHost) + private let approvalBootstrapFactory: ApprovalBootstrapFactory private var sdk: PaykitSdk? private var activeAuthRequest: Paykit.PubkyAuthRequest? private var activeAuthRequestID: UUID? + init( + approvalBootstrapFactory: @escaping ApprovalBootstrapFactory = PubkySessionBootstrap.withPubkyClientConfig(clientId:pubkyClient:) + ) { + self.approvalBootstrapFactory = approvalBootstrapFactory + } + func initialize() async throws { try await operationLock.withLock { var sdk = try handle() @@ -1024,7 +1033,7 @@ actor PaykitSdkService { ) } - private func approvalBootstrap(authUrl: String, approvedClientID: String) throws -> PubkySessionBootstrap { + func approvalBootstrap(authUrl: String, approvedClientID: String) throws -> PubkySessionBootstrap { let requestClientID = try Paykit.parsePubkyAuthUrl(authUrl: authUrl).clientId guard !approvedClientID.isEmpty, approvedClientID == requestClientID else { throw AppError( @@ -1032,10 +1041,7 @@ actor PaykitSdkService { debugMessage: "Approved Pubky client ID does not match auth request" ) } - return try PubkySessionBootstrap.withPubkyClientConfig( - clientId: approvedClientID, - pubkyClient: pubkyClientConfig - ) + return try approvalBootstrapFactory(requestClientID, pubkyClientConfig) } nonisolated static func makePubkyClientConfig(localTestnetHost: String?) -> PubkyClientConfig { diff --git a/BitkitTests/PaykitSdkClientConfigTests.swift b/BitkitTests/PaykitSdkClientConfigTests.swift index d344317aa..981c0ef6d 100644 --- a/BitkitTests/PaykitSdkClientConfigTests.swift +++ b/BitkitTests/PaykitSdkClientConfigTests.swift @@ -3,6 +3,11 @@ import Paykit import XCTest final class PaykitSdkClientConfigTests: XCTestCase { + private let externalAuthURL = + "pubkyauth://signin_grant?caps=/pub/example/:rw&relay=https://httprelay.pubky.app/inbox/" + + "&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s" + + "&cid=paykit.test&cpk=5jsjx1o6fzu6aeeo697r3i5rx15zq41kikcye8wtwdqm4nb4tryo" + func testClientIDUsesBitkitOwnedDomain() { let expectedClientID = Env.network == .bitcoin ? "bitkit.to" : "staging.bitkit.to" @@ -21,6 +26,39 @@ final class PaykitSdkClientConfigTests: XCTestCase { XCTAssertEqual(config.localTestnetHost, "192.0.2.1") } + func testApprovalBootstrapUsesExternalRequesterClientID() async throws { + var configuredClientID: String? + let service = PaykitSdkService { clientID, _ in + configuredClientID = clientID + return PubkySessionBootstrap(noPointer: .init()) + } + + _ = try await service.approvalBootstrap( + authUrl: externalAuthURL, + approvedClientID: "paykit.test" + ) + + XCTAssertEqual(configuredClientID, "paykit.test") + } + + func testApprovalBootstrapRejectsMismatchedClientID() async { + var didCreateBootstrap = false + let service = PaykitSdkService { _, _ in + didCreateBootstrap = true + return PubkySessionBootstrap(noPointer: .init()) + } + + do { + _ = try await service.approvalBootstrap( + authUrl: externalAuthURL, + approvedClientID: "different.test" + ) + XCTFail("Expected a mismatched client ID to be rejected") + } catch {} + + XCTAssertFalse(didCreateBootstrap) + } + func testStoredSessionCanBeDeferredDuringSdkInitialization() { let error = PaykitError.Identity(code: "identity_error", context: "restore Pubky grant session from platform provider") From c23748a22f8d9129cae4fdb560ac875115ced7f7 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 3 Sep 2026 13:00:03 -0500 Subject: [PATCH 09/40] fix: bound auth requester label --- Bitkit/Resources/Localization/en.lproj/Localizable.strings | 2 +- .../Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 77d3e1133..7fe9ba826 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -693,7 +693,7 @@ "pubky_auth__title" = "Authorize"; "pubky_auth__description_prefix" = "A service is requesting permission to access and edit your "; "pubky_auth__description_suffix" = " data."; -"pubky_auth__requester" = "Requested by {clientId}"; +"pubky_auth__requester" = "Requester ID: {clientId}"; "pubky_auth__requested_permissions" = "REQUESTED PERMISSIONS"; "pubky_auth__watch_only_account_default_name" = "{service} account"; "pubky_auth__watch_only_account_fallback_name" = "Paykit server account"; diff --git a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift index df1aae581..c7842ee46 100644 --- a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift +++ b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift @@ -232,6 +232,8 @@ struct PubkyAuthApprovalSheet: View { .padding(.bottom, 8) BodySText(t("pubky_auth__requester", variables: ["clientId": config.request.clientID])) + .lineLimit(1) + .truncationMode(.tail) .padding(.bottom, 32) permissionsSection From 35816ddfe9d1230430ae35213f7a61340bac81cb Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 3 Sep 2026 17:12:51 -0500 Subject: [PATCH 10/40] fix: clear abandoned paykit sessions --- Bitkit/Managers/PubkyProfileManager.swift | 78 ++++++++++++++++------ BitkitTests/PubkyProfileManagerTests.swift | 28 ++++++-- 2 files changed, 82 insertions(+), 24 deletions(-) diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index fe8ea490d..41a2fa0b9 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -310,13 +310,7 @@ class PubkyProfileManager: ObservableObject { cacheProfileMetadata(createdProfile) } catch { let profileCreationError = error - do { - try await Task.detached { - try await PubkyService.signOut() - }.value - } catch { - Logger.warn("Failed to revoke incomplete Pubky profile session: \(error)", context: "PubkyProfileManager") - } + await discardAbandonedSession() throw profileCreationError } @@ -512,10 +506,8 @@ class PubkyProfileManager: ObservableObject { try await completeAuthentication( completeAuth: { _ = try await PubkyService.completeAuth() }, currentPublicKey: { await PubkyService.currentPublicKey() }, - revokeSessionAccess: { - try await Task.detached { - try await PubkyService.signOut() - }.value + discardSessionAccess: { + await self.discardAbandonedSession() } ) } @@ -524,7 +516,7 @@ class PubkyProfileManager: ObservableObject { private func completeAuthentication( completeAuth: @escaping () async throws -> Void, currentPublicKey: @escaping () async -> String?, - revokeSessionAccess: @escaping () async throws -> Void + discardSessionAccess: @escaping () async -> Void ) async throws -> String { guard let attemptID = activeAuthAttemptID else { throw CancellationError() @@ -557,14 +549,20 @@ class PubkyProfileManager: ObservableObject { await loadProfile() return pk } catch is CancellationError { - await revokeCompletedAuthSessionIfNeeded(didCompleteAuth, revokeSessionAccess: revokeSessionAccess) + await discardCompletedAuthSessionIfNeeded( + didCompleteAuth, + discardSessionAccess: discardSessionAccess + ) if activeAuthAttemptID == attemptID { activeAuthAttemptID = nil restoreAuthStateAfterAuthFlow() } throw CancellationError() } catch let serviceError as PubkyServiceError { - await revokeCompletedAuthSessionIfNeeded(didCompleteAuth, revokeSessionAccess: revokeSessionAccess) + await discardCompletedAuthSessionIfNeeded( + didCompleteAuth, + discardSessionAccess: discardSessionAccess + ) guard activeAuthAttemptID == attemptID else { throw CancellationError() } @@ -573,7 +571,10 @@ class PubkyProfileManager: ObservableObject { restoreAuthStateAfterAuthFlow() throw serviceError } catch { - await revokeCompletedAuthSessionIfNeeded(didCompleteAuth, revokeSessionAccess: revokeSessionAccess) + await discardCompletedAuthSessionIfNeeded( + didCompleteAuth, + discardSessionAccess: discardSessionAccess + ) guard activeAuthAttemptID == attemptID else { throw CancellationError() } @@ -584,15 +585,42 @@ class PubkyProfileManager: ObservableObject { } } - private func revokeCompletedAuthSessionIfNeeded( + private func discardCompletedAuthSessionIfNeeded( _ didCompleteAuth: Bool, - revokeSessionAccess: @escaping () async throws -> Void + discardSessionAccess: @escaping () async -> Void ) async { guard didCompleteAuth else { return } + await discardSessionAccess() + } + + private func discardAbandonedSession() async { + await discardAbandonedSession( + revokeSessionAccess: { + try await Task.detached { + try await PubkyService.signOut() + }.value + }, + forgetSessionAccess: { + try await Task.detached { + try await PubkyService.forgetSessionAccess() + }.value + } + ) + } + + private func discardAbandonedSession( + revokeSessionAccess: @escaping () async throws -> Void, + forgetSessionAccess: @escaping () async throws -> Void + ) async { do { try await revokeSessionAccess() } catch { - Logger.warn("Failed to revoke canceled Pubky auth session: \(error)", context: "PubkyProfileManager") + Logger.warn("Failed to revoke abandoned Pubky session: \(error)", context: "PubkyProfileManager") + do { + try await forgetSessionAccess() + } catch { + Logger.warn("Failed to forget abandoned Pubky session access: \(error)", context: "PubkyProfileManager") + } } } @@ -630,12 +658,22 @@ class PubkyProfileManager: ObservableObject { func completeAuthenticationForTesting( completeAuth: @escaping () async throws -> Void, currentPublicKey: @escaping () async -> String?, - revokeSessionAccess: @escaping () async throws -> Void + discardSessionAccess: @escaping () async -> Void ) async throws -> String { try await completeAuthentication( completeAuth: completeAuth, currentPublicKey: currentPublicKey, - revokeSessionAccess: revokeSessionAccess + discardSessionAccess: discardSessionAccess + ) + } + + func discardAbandonedSessionForTesting( + revokeSessionAccess: @escaping () async throws -> Void, + forgetSessionAccess: @escaping () async throws -> Void + ) async { + await discardAbandonedSession( + revokeSessionAccess: revokeSessionAccess, + forgetSessionAccess: forgetSessionAccess ) } #endif diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index 881cfffe3..745d04c83 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -134,7 +134,7 @@ final class PubkyProfileManagerTests: XCTestCase { func testCompleteAuthenticationRevokesSessionWhenAuthIsCanceledAfterCompletion() async { let manager = PubkyProfileManager() let attemptID = UUID() - var didRevokeSession = false + var didDiscardSession = false manager.setActiveAuthAttemptIDForTesting(attemptID) manager.authState = .authenticating @@ -147,19 +147,39 @@ final class PubkyProfileManagerTests: XCTestCase { currentPublicKey: { "pubky_test" }, - revokeSessionAccess: { - didRevokeSession = true + discardSessionAccess: { + didDiscardSession = true } ) XCTFail("Expected cancellation") } catch is CancellationError { - XCTAssertTrue(didRevokeSession) + XCTAssertTrue(didDiscardSession) XCTAssertNil(manager.activeAuthAttemptIDForTesting) } catch { XCTFail("Expected CancellationError, got \(error)") } } + @MainActor + func testDiscardAbandonedSessionForgetsLocalAccessWhenRevocationFails() async { + let manager = PubkyProfileManager() + var didRevokeSession = false + var didForgetSession = false + + await manager.discardAbandonedSessionForTesting( + revokeSessionAccess: { + didRevokeSession = true + throw PubkyServiceError.authFailed("offline") + }, + forgetSessionAccess: { + didForgetSession = true + } + ) + + XCTAssertTrue(didRevokeSession) + XCTAssertTrue(didForgetSession) + } + // MARK: - HomegateResponse Decoding private typealias HomegateResponse = PubkyProfileManager.HomegateResponse From d90a6a92ae09b44985858acb14bbf9366540c8ca Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 4 Sep 2026 07:23:39 -0500 Subject: [PATCH 11/40] fix: harden Paykit session recovery --- Bitkit/AppScene.swift | 5 - Bitkit/Managers/PubkyProfileManager.swift | 22 +++- .../PrivatePaykitService+Contacts.swift | 5 +- Bitkit/Services/PubkyService.swift | 19 +++- Bitkit/Services/PublicPaykitService.swift | 4 - BitkitTests/PaykitSdkClientConfigTests.swift | 18 +++ BitkitTests/PrivatePaykitServiceTests.swift | 23 ++++ BitkitTests/PubkyProfileManagerTests.swift | 105 +++++++++++++++++- BitkitTests/PublicPaykitServiceTests.swift | 23 ++-- 9 files changed, 200 insertions(+), 24 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index cfd2ac316..615814c74 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -936,11 +936,6 @@ struct AppScene: View { switch PublicPaykitService.pendingReconciliationMode() { case .publishEndpoints: try await PublicPaykitService.syncCurrentPublishedEndpoints(wallet: wallet) - case .publishReceiverMarker: - try await PublicPaykitService.syncLocalReceiverMarker( - publicSharingEnabled: false, - privateSharingEnabled: true - ) case .removePublishedState: try await PublicPaykitService.removePublishedEndpoints() try await PublicPaykitService.syncLocalReceiverMarker( diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 41a2fa0b9..efb34e4d8 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -364,6 +364,7 @@ class PubkyProfileManager: ObservableObject { Logger.info("Bitkit profile storage already missing, continuing sign out", context: "PubkyProfileManager") } + Self.clearPaykitSharingAfterProfileDeletion() try await signOut(cleanPrivatePaykitEndpoints: false) } @@ -524,8 +525,8 @@ class PubkyProfileManager: ObservableObject { var didCompleteAuth = false do { - try await completeAuth() didCompleteAuth = true + try await completeAuth() try Task.checkCancellation() guard activeAuthAttemptID == attemptID else { throw CancellationError() @@ -844,6 +845,19 @@ class PubkyProfileManager: ObservableObject { } } + static func clearPaykitSharingAfterProfileDeletion( + defaults: UserDefaults = .standard, + setPublicReconciliationPending: (Bool) -> Void = PublicPaykitService.setCleanupPending + ) { + let hadPublishedState = defaults.bool(forKey: PublicPaykitService.publishingEnabledKey) || + defaults.bool(forKey: PrivatePaykitService.publishingEnabledKey) + defaults.set(false, forKey: PublicPaykitService.publishingEnabledKey) + defaults.set(false, forKey: PrivatePaykitService.publishingEnabledKey) + if hadPublishedState { + setPublicReconciliationPending(true) + } + } + func refreshSessionIfPossible(after error: Error) async -> Bool { await Self.refreshSessionIfPossible( after: error, @@ -962,7 +976,11 @@ class PubkyProfileManager: ObservableObject { try await PubkyService.importExternalSession(secret: $0) } ) async throws { - try await forgetSessionAccess() + do { + try await forgetSessionAccess() + } catch { + Logger.warn("Failed to forget existing Pubky session before restore: \(error)", context: "PubkyProfileManager") + } switch backup?.kind { case .none: diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index e81d23d43..d76b11cf7 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -270,8 +270,11 @@ extension PrivatePaykitService { if isFullCleanupPending, Self.fullCleanupReconciliationMode() == .restoreSavedContacts { + let restoreKeys = savedKeys.union(knownSavedContactKeys) + guard !restoreKeys.isEmpty else { return } + let error = await prepareSavedContacts( - Array(savedKeys), + Array(restoreKeys), wallet: wallet, requireImmediatePublication: true ) diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 6f9b51ae7..48d8e263d 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -1217,8 +1217,7 @@ private final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecke func clearSessionAccess() throws { clearLiveSessionAccess() - try Keychain.delete(key: .pubkySecretKey) - try Keychain.delete(key: .paykitSession) + try PubkySessionAccessTeardown.clear { try Keychain.delete(key: $0) } } func loadLocalSecretKey() throws -> PubkyLocalSecretKey? { @@ -1238,6 +1237,22 @@ private final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecke } } +enum PubkySessionAccessTeardown { + static func clear(deleteKeychainValue: (KeychainEntryType) throws -> Void) throws { + var firstError: Error? + for key in [KeychainEntryType.paykitSession, .pubkySecretKey] { + do { + try deleteKeychainValue(key) + } catch { + firstError = firstError ?? error + } + } + if let firstError { + throw firstError + } + } +} + enum PaykitReceiverNoiseKeyDerivation { private static let domain = "bitkit/paykit/receiver-noise-key" private static let version = "v1" diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift index 83ab7015d..c0bc66173 100644 --- a/Bitkit/Services/PublicPaykitService.swift +++ b/Bitkit/Services/PublicPaykitService.swift @@ -97,7 +97,6 @@ enum PublicPaykitService { enum PendingReconciliationMode: Equatable { case publishEndpoints - case publishReceiverMarker case removePublishedState } @@ -105,9 +104,6 @@ enum PublicPaykitService { if defaults.bool(forKey: publishingEnabledKey) { return .publishEndpoints } - if defaults.bool(forKey: PrivatePaykitService.publishingEnabledKey) { - return .publishReceiverMarker - } return .removePublishedState } diff --git a/BitkitTests/PaykitSdkClientConfigTests.swift b/BitkitTests/PaykitSdkClientConfigTests.swift index 981c0ef6d..4a3f5b978 100644 --- a/BitkitTests/PaykitSdkClientConfigTests.swift +++ b/BitkitTests/PaykitSdkClientConfigTests.swift @@ -72,4 +72,22 @@ final class PaykitSdkClientConfigTests: XCTestCase { XCTAssertFalse(PaykitSdkService.shouldDeferStaleSession(error: staleSession, hasStoredSession: false)) XCTAssertFalse(PaykitSdkService.shouldDeferStaleSession(error: unrelatedError, hasStoredSession: true)) } + + func testSessionAccessTeardownAttemptsBothCredentialsWithSessionFirst() { + var attemptedKeys: [String] = [] + + XCTAssertThrowsError( + try PubkySessionAccessTeardown.clear { key in + attemptedKeys.append(key.storageKey) + if key.storageKey == KeychainEntryType.paykitSession.storageKey { + throw KeychainError.failedToDelete + } + } + ) + + XCTAssertEqual( + attemptedKeys, + [KeychainEntryType.paykitSession.storageKey, KeychainEntryType.pubkySecretKey.storageKey] + ) + } } diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index 48c46e0a0..7b536082b 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -41,6 +41,29 @@ final class PrivatePaykitServiceTests: XCTestCase { } } + @MainActor + func testPendingEndpointReconciliationKeepsKnownContactsWhenLoadedListIsEmpty() async { + let defaults = UserDefaults.standard + let previousCleanupPending = defaults.object(forKey: PrivatePaykitService.cleanupPendingKey) + let previousPublishingEnabled = defaults.object(forKey: PrivatePaykitService.publishingEnabledKey) + defer { + defaults.set(previousCleanupPending, forKey: PrivatePaykitService.cleanupPendingKey) + defaults.set(previousPublishingEnabled, forKey: PrivatePaykitService.publishingEnabledKey) + } + + defaults.set(true, forKey: PrivatePaykitService.cleanupPendingKey) + defaults.set(true, forKey: PrivatePaykitService.publishingEnabledKey) + let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + let service = PrivatePaykitService() + _ = await service.rememberSavedContacts([publicKey], replacing: true) + + await service.retryPendingEndpointReconciliation(wallet: WalletViewModel(), savedPublicKeys: []) + + let knownSavedContactKeys = await service.knownSavedContactKeys + XCTAssertEqual(knownSavedContactKeys, [publicKey]) + XCTAssertTrue(defaults.bool(forKey: PrivatePaykitService.cleanupPendingKey)) + } + func testReceiverNoiseDerivationMatchesCrossPlatformVector() { let seed = ( "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e534955" + diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index 745d04c83..82c5880a6 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -120,7 +120,7 @@ final class PubkyProfileManagerTests: XCTestCase { var privatePending = false PubkyProfileManager.markPaykitReconciliationPendingAfterFailedSignOut( - publicSharingEnabled: false, + publicSharingEnabled: true, privateSharingEnabled: true, setPublicReconciliationPending: { publicPending = $0 }, setPrivateReconciliationPending: { privatePending = $0 } @@ -130,6 +130,24 @@ final class PubkyProfileManagerTests: XCTestCase { XCTAssertTrue(privatePending) } + @MainActor + func testProfileDeletionQueuesRemovalRatherThanRepublish() throws { + try withIsolatedDefaults { defaults in + defaults.set(true, forKey: PublicPaykitService.publishingEnabledKey) + defaults.set(true, forKey: PrivatePaykitService.publishingEnabledKey) + var publicPending = false + + PubkyProfileManager.clearPaykitSharingAfterProfileDeletion( + defaults: defaults, + setPublicReconciliationPending: { publicPending = $0 } + ) + + XCTAssertTrue(publicPending) + XCTAssertEqual(PublicPaykitService.pendingReconciliationMode(defaults: defaults), .removePublishedState) + XCTAssertEqual(PrivatePaykitService.fullCleanupReconciliationMode(defaults: defaults), .removePublishedState) + } + } + @MainActor func testCompleteAuthenticationRevokesSessionWhenAuthIsCanceledAfterCompletion() async { let manager = PubkyProfileManager() @@ -160,6 +178,29 @@ final class PubkyProfileManagerTests: XCTestCase { } } + @MainActor + func testCompleteAuthenticationRevokesSessionWhenActivationThrows() async { + let errors: [Error] = [PubkyServiceError.authFailed("offline"), CancellationError()] + + for thrownError in errors { + let manager = PubkyProfileManager() + manager.setActiveAuthAttemptIDForTesting(UUID()) + manager.authState = .authenticating + var didDiscardSession = false + + do { + try await manager.completeAuthenticationForTesting( + completeAuth: { throw thrownError }, + currentPublicKey: { "pubky_test" }, + discardSessionAccess: { didDiscardSession = true } + ) + XCTFail("Expected authentication activation to fail") + } catch { + XCTAssertTrue(didDiscardSession) + } + } + } + @MainActor func testDiscardAbandonedSessionForgetsLocalAccessWhenRevocationFails() async { let manager = PubkyProfileManager() @@ -524,6 +565,59 @@ final class PubkyProfileManagerTests: XCTestCase { XCTAssertNil(store[KeychainEntryType.pubkySecretKey.storageKey]) } + func testRestoreSessionBackupStateReplacesSessionWhenForgetFails() async throws { + var store = makeKeychainStore( + paykitSession: "stale-session", + pubkySecretKey: "stale-local-secret" + ) + + try await PubkyProfileManager.restoreSessionBackupState( + PubkySessionBackupV1(kind: .externalSession, sessionSecret: "backup-session"), + loadKeychainString: { store[$0.storageKey] }, + persistKeychainString: { store[$0.storageKey] = $1 }, + deleteKeychainValue: { store.removeValue(forKey: $0.storageKey) }, + forgetSessionAccess: { throw PubkyServiceError.authFailed("offline") }, + signInWithSecretKey: { _ in + XCTFail("External session restore should not sign in with a local secret") + return "unused-session" + }, + importExternalSession: { session in + store[KeychainEntryType.paykitSession.storageKey] = session + store.removeValue(forKey: KeychainEntryType.pubkySecretKey.storageKey) + return "pubky_external" + } + ) + + XCTAssertEqual(store[KeychainEntryType.paykitSession.storageKey], "backup-session") + XCTAssertNil(store[KeychainEntryType.pubkySecretKey.storageKey]) + } + + func testRestoreSessionBackupStateClearsCredentialsWhenForgetFailsWithoutBackup() async throws { + var store = makeKeychainStore( + paykitSession: "stale-session", + pubkySecretKey: "stale-local-secret" + ) + + try await PubkyProfileManager.restoreSessionBackupState( + nil, + loadKeychainString: { store[$0.storageKey] }, + persistKeychainString: { store[$0.storageKey] = $1 }, + deleteKeychainValue: { store.removeValue(forKey: $0.storageKey) }, + forgetSessionAccess: { throw PubkyServiceError.authFailed("offline") }, + signInWithSecretKey: { _ in + XCTFail("Missing pubky state should not sign in") + return "unused-session" + }, + importExternalSession: { _ in + XCTFail("Missing pubky state should not import a session") + return "pubky_unused" + } + ) + + XCTAssertNil(store[KeychainEntryType.paykitSession.storageKey]) + XCTAssertNil(store[KeychainEntryType.pubkySecretKey.storageKey]) + } + func testRestoreSessionBackupStateForLocalSeedDerivesSecretAndClearsSession() async throws { var store = makeKeychainStore( mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", @@ -689,6 +783,15 @@ final class PubkyProfileManagerTests: XCTestCase { ) } + private func withIsolatedDefaults(_ body: (UserDefaults) throws -> Void) throws { + let suiteName = "PubkyProfileManagerTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + + try body(defaults) + } + private func makeKeychainStore( mnemonic: String? = nil, paykitSession: String? = nil, diff --git a/BitkitTests/PublicPaykitServiceTests.swift b/BitkitTests/PublicPaykitServiceTests.swift index c1602ae1e..e17abd75a 100644 --- a/BitkitTests/PublicPaykitServiceTests.swift +++ b/BitkitTests/PublicPaykitServiceTests.swift @@ -187,15 +187,20 @@ final class PublicPaykitServiceTests: XCTestCase { } } - func testPendingReconciliationRestoresPrivateOnlyReceiverMarker() throws { - try withIsolatedDefaults { defaults in - defaults.set(false, forKey: PublicPaykitService.publishingEnabledKey) - defaults.set(true, forKey: PrivatePaykitService.publishingEnabledKey) - - XCTAssertEqual( - PublicPaykitService.pendingReconciliationMode(defaults: defaults), - .publishReceiverMarker - ) + func testPendingReconciliationHandlesWriterProducedSharingStates() throws { + let expectedModes: [(publicEnabled: Bool, privateEnabled: Bool, mode: PublicPaykitService.PendingReconciliationMode)] = [ + (true, true, .publishEndpoints), + (true, false, .publishEndpoints), + (false, false, .removePublishedState), + ] + + for expected in expectedModes { + try withIsolatedDefaults { defaults in + defaults.set(expected.publicEnabled, forKey: PublicPaykitService.publishingEnabledKey) + defaults.set(expected.privateEnabled, forKey: PrivatePaykitService.publishingEnabledKey) + + XCTAssertEqual(PublicPaykitService.pendingReconciliationMode(defaults: defaults), expected.mode) + } } } From 2777d7992189de34a35a2815fcbc4c46cd2e1502 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 4 Sep 2026 07:33:52 -0500 Subject: [PATCH 12/40] fix: reset forgotten Paykit sessions --- Bitkit/Services/PubkyService.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 48d8e263d..9ec7a00ac 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -833,12 +833,12 @@ actor PaykitSdkService { } func forgetSessionAccess() async throws { + defer { resetRuntime() } try await withStateRevisionTracking { sdk in activeAuthRequest = nil activeAuthRequestID = nil _ = try await sdk.forgetSessionAccess() } - resetRuntime() } func clearState() async { From 57dc1cccc0f19abf4d4da6157e087c16301994ad Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 4 Sep 2026 17:14:06 -0500 Subject: [PATCH 13/40] fix: preserve payment request history --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- changelog.d/next/697.security.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index e428fe937..0133251f0 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1182,7 +1182,7 @@ repositoryURL = "https://github.com/pubky/paykit-rs"; requirement = { kind = exactVersion; - version = "0.1.0-rc50"; + version = "0.1.0-rc51"; }; }; 18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 8167000f0..5e8860816 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pubky/paykit-rs", "state" : { - "revision" : "49f09ee439884d1ba2708f50e66022cda22cf10f", - "version" : "0.1.0-rc50" + "revision" : "80f3d81898ab23279134797e5625cae7232dad15", + "version" : "0.1.0-rc51" } }, { diff --git a/changelog.d/next/697.security.md b/changelog.d/next/697.security.md index bab344490..1088411f6 100644 --- a/changelog.d/next/697.security.md +++ b/changelog.d/next/697.security.md @@ -1 +1 @@ -Updated Pubky authentication to use app-scoped grants, authorize external services, and securely sign out. +Added app-scoped Pubky authorization and secure sign-out, and fixed missing payment requests in history. From 748aaf08893bba5865eaf7b6363664573d0de0e1 Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 6 Sep 2026 17:00:40 +0200 Subject: [PATCH 14/40] fix: preserve private paykit receiver marker --- Bitkit/AppScene.swift | 5 +---- BitkitTests/PublicPaykitServiceTests.swift | 1 + 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 615814c74..76754898e 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -938,10 +938,7 @@ struct AppScene: View { try await PublicPaykitService.syncCurrentPublishedEndpoints(wallet: wallet) case .removePublishedState: try await PublicPaykitService.removePublishedEndpoints() - try await PublicPaykitService.syncLocalReceiverMarker( - publicSharingEnabled: false, - privateSharingEnabled: false - ) + try await PublicPaykitService.syncLocalReceiverMarker() } PublicPaykitService.setCleanupPending(false) } catch { diff --git a/BitkitTests/PublicPaykitServiceTests.swift b/BitkitTests/PublicPaykitServiceTests.swift index e17abd75a..0b6ed8fd4 100644 --- a/BitkitTests/PublicPaykitServiceTests.swift +++ b/BitkitTests/PublicPaykitServiceTests.swift @@ -191,6 +191,7 @@ final class PublicPaykitServiceTests: XCTestCase { let expectedModes: [(publicEnabled: Bool, privateEnabled: Bool, mode: PublicPaykitService.PendingReconciliationMode)] = [ (true, true, .publishEndpoints), (true, false, .publishEndpoints), + (false, true, .removePublishedState), (false, false, .removePublishedState), ] From 344720872b38ce8305bf325a8b95ce30e64be023 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 7 Sep 2026 20:13:26 +0300 Subject: [PATCH 15/40] fix: scope auth recovery to its completed session --- Bitkit/Managers/PubkyProfileManager.swift | 35 ++++---- Bitkit/Services/PubkyService.swift | 81 +++++++++++++++++- Bitkit/Views/Profile/PubkyChoiceView.swift | 3 +- Bitkit/Views/Profile/PubkyRingAuthView.swift | 3 +- BitkitTests/PaykitSdkClientConfigTests.swift | 86 +++++++++++++++++++- BitkitTests/PubkyProfileManagerTests.swift | 33 +++++++- 6 files changed, 211 insertions(+), 30 deletions(-) diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index efb34e4d8..2c0001981 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -505,28 +505,29 @@ class PubkyProfileManager: ObservableObject { @discardableResult func completeAuthentication() async throws -> String { try await completeAuthentication( - completeAuth: { _ = try await PubkyService.completeAuth() }, + completeAuth: { try await PubkyService.completeAuth() }, currentPublicKey: { await PubkyService.currentPublicKey() }, - discardSessionAccess: { - await self.discardAbandonedSession() + discardSessionAccess: { sessionSecret in + await Task.detached { + await PaykitSdkService.shared.discardCompletedAuthSession(sessionSecret: sessionSecret) + }.value } ) } @discardableResult private func completeAuthentication( - completeAuth: @escaping () async throws -> Void, + completeAuth: @escaping () async throws -> String, currentPublicKey: @escaping () async -> String?, - discardSessionAccess: @escaping () async -> Void + discardSessionAccess: @escaping (String) async -> Void ) async throws -> String { guard let attemptID = activeAuthAttemptID else { throw CancellationError() } - var didCompleteAuth = false + var completedSessionSecret: String? do { - didCompleteAuth = true - try await completeAuth() + completedSessionSecret = try await completeAuth() try Task.checkCancellation() guard activeAuthAttemptID == attemptID else { throw CancellationError() @@ -551,7 +552,7 @@ class PubkyProfileManager: ObservableObject { return pk } catch is CancellationError { await discardCompletedAuthSessionIfNeeded( - didCompleteAuth, + completedSessionSecret, discardSessionAccess: discardSessionAccess ) if activeAuthAttemptID == attemptID { @@ -561,7 +562,7 @@ class PubkyProfileManager: ObservableObject { throw CancellationError() } catch let serviceError as PubkyServiceError { await discardCompletedAuthSessionIfNeeded( - didCompleteAuth, + completedSessionSecret, discardSessionAccess: discardSessionAccess ) guard activeAuthAttemptID == attemptID else { @@ -573,7 +574,7 @@ class PubkyProfileManager: ObservableObject { throw serviceError } catch { await discardCompletedAuthSessionIfNeeded( - didCompleteAuth, + completedSessionSecret, discardSessionAccess: discardSessionAccess ) guard activeAuthAttemptID == attemptID else { @@ -587,11 +588,11 @@ class PubkyProfileManager: ObservableObject { } private func discardCompletedAuthSessionIfNeeded( - _ didCompleteAuth: Bool, - discardSessionAccess: @escaping () async -> Void + _ completedSessionSecret: String?, + discardSessionAccess: @escaping (String) async -> Void ) async { - guard didCompleteAuth else { return } - await discardSessionAccess() + guard let completedSessionSecret else { return } + await discardSessionAccess(completedSessionSecret) } private func discardAbandonedSession() async { @@ -657,9 +658,9 @@ class PubkyProfileManager: ObservableObject { @discardableResult func completeAuthenticationForTesting( - completeAuth: @escaping () async throws -> Void, + completeAuth: @escaping () async throws -> String, currentPublicKey: @escaping () async -> String?, - discardSessionAccess: @escaping () async -> Void + discardSessionAccess: @escaping (String) async -> Void ) async throws -> String { try await completeAuthentication( completeAuth: completeAuth, diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 9ec7a00ac..2e37aa2ae 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -448,12 +448,87 @@ actor PaykitSdkService { } let previousPublicKey = await currentSdkStatePublicKey() - try await activateBootstrapResult(result, previousPublicKey: previousPublicKey, shouldStoreLocalSecret: false) + 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 + } + ) + 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() - return result.sessionAccess.exportSessionSecret() } } + 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 true + } + func cancelAuth() { activeAuthRequest = nil activeAuthRequestID = nil @@ -1020,6 +1095,8 @@ actor PaykitSdkService { } return context == "restore Pubky grant session from platform provider" + || context == "Pubky session must be grant-backed" + || context.hasPrefix("Pubky grant client ID `") } private nonisolated static func canReceivePrivatePaymentDetails(marker: Paykit.PaykitReceiverMarker?) -> Bool { diff --git a/Bitkit/Views/Profile/PubkyChoiceView.swift b/Bitkit/Views/Profile/PubkyChoiceView.swift index 123721454..026b37af4 100644 --- a/Bitkit/Views/Profile/PubkyChoiceView.swift +++ b/Bitkit/Views/Profile/PubkyChoiceView.swift @@ -183,8 +183,7 @@ struct PubkyChoiceView: View { isLoadingAfterAuth = true await navigateAfterAuth(publicKey: publicKey) } catch is CancellationError { - isWaitingForRing = false - await pubkyProfile.cancelAuthentication() + return } catch { isWaitingForRing = false app.toast(type: .error, title: t("profile__auth_error_title"), description: error.localizedDescription) diff --git a/Bitkit/Views/Profile/PubkyRingAuthView.swift b/Bitkit/Views/Profile/PubkyRingAuthView.swift index 703b0236b..a4f956e8f 100644 --- a/Bitkit/Views/Profile/PubkyRingAuthView.swift +++ b/Bitkit/Views/Profile/PubkyRingAuthView.swift @@ -181,8 +181,7 @@ struct PubkyRingAuthView: View { isLoadingAfterAuth = true await navigateAfterAuth(publicKey: publicKey) } catch is CancellationError { - isWaitingForRing = false - await pubkyProfile.cancelAuthentication() + return } catch { isWaitingForRing = false app.toast(type: .error, title: t("profile__auth_error_title"), description: error.localizedDescription) diff --git a/BitkitTests/PaykitSdkClientConfigTests.swift b/BitkitTests/PaykitSdkClientConfigTests.swift index 4a3f5b978..862ccfcc2 100644 --- a/BitkitTests/PaykitSdkClientConfigTests.swift +++ b/BitkitTests/PaykitSdkClientConfigTests.swift @@ -60,9 +60,15 @@ final class PaykitSdkClientConfigTests: XCTestCase { } func testStoredSessionCanBeDeferredDuringSdkInitialization() { - let error = PaykitError.Identity(code: "identity_error", context: "restore Pubky grant session from platform provider") - - XCTAssertTrue(PaykitSdkService.shouldDeferStaleSession(error: error, hasStoredSession: true)) + for context in [ + "restore Pubky grant session from platform provider", + "Pubky session must be grant-backed", + "Pubky grant client ID `old.bitkit.to` did not match `staging.bitkit.to`", + ] { + let error = PaykitError.Identity(code: "identity_error", context: context) + XCTAssertTrue(PaykitSdkService.shouldDeferStaleSession(error: error, hasStoredSession: true)) + XCTAssertFalse(PaykitSdkService.shouldDeferStaleSession(error: error, hasStoredSession: false)) + } } func testMissingSessionOrUnrelatedIdentityFailureIsNotDeferred() { @@ -90,4 +96,78 @@ 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/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index 82c5880a6..77ebf71fc 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -161,11 +161,13 @@ final class PubkyProfileManagerTests: XCTestCase { try await manager.completeAuthenticationForTesting( completeAuth: { manager.setActiveAuthAttemptIDForTesting(nil) + return "new-session" }, currentPublicKey: { "pubky_test" }, - discardSessionAccess: { + discardSessionAccess: { sessionSecret in + XCTAssertEqual(sessionSecret, "new-session") didDiscardSession = true } ) @@ -179,7 +181,7 @@ final class PubkyProfileManagerTests: XCTestCase { } @MainActor - func testCompleteAuthenticationRevokesSessionWhenActivationThrows() async { + func testCompleteAuthenticationPreservesSessionWhenRelayFails() async { let errors: [Error] = [PubkyServiceError.authFailed("offline"), CancellationError()] for thrownError in errors { @@ -192,15 +194,38 @@ final class PubkyProfileManagerTests: XCTestCase { try await manager.completeAuthenticationForTesting( completeAuth: { throw thrownError }, currentPublicKey: { "pubky_test" }, - discardSessionAccess: { didDiscardSession = true } + discardSessionAccess: { _ in didDiscardSession = true } ) XCTFail("Expected authentication activation to fail") } catch { - XCTAssertTrue(didDiscardSession) + 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() From 70c51fa8df205fc0190d59733386f74738eed17f Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 7 Sep 2026 21:23:01 +0300 Subject: [PATCH 16/40] refactor: remove paykit migration handling --- Bitkit/Services/PubkyService.swift | 2 -- BitkitTests/PaykitSdkClientConfigTests.swift | 12 +++--------- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 2e37aa2ae..da4d702ab 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -1095,8 +1095,6 @@ actor PaykitSdkService { } return context == "restore Pubky grant session from platform provider" - || context == "Pubky session must be grant-backed" - || context.hasPrefix("Pubky grant client ID `") } private nonisolated static func canReceivePrivatePaymentDetails(marker: Paykit.PaykitReceiverMarker?) -> Bool { diff --git a/BitkitTests/PaykitSdkClientConfigTests.swift b/BitkitTests/PaykitSdkClientConfigTests.swift index 862ccfcc2..3c461ba70 100644 --- a/BitkitTests/PaykitSdkClientConfigTests.swift +++ b/BitkitTests/PaykitSdkClientConfigTests.swift @@ -60,15 +60,9 @@ final class PaykitSdkClientConfigTests: XCTestCase { } func testStoredSessionCanBeDeferredDuringSdkInitialization() { - for context in [ - "restore Pubky grant session from platform provider", - "Pubky session must be grant-backed", - "Pubky grant client ID `old.bitkit.to` did not match `staging.bitkit.to`", - ] { - let error = PaykitError.Identity(code: "identity_error", context: context) - XCTAssertTrue(PaykitSdkService.shouldDeferStaleSession(error: error, hasStoredSession: true)) - XCTAssertFalse(PaykitSdkService.shouldDeferStaleSession(error: error, hasStoredSession: false)) - } + let error = PaykitError.Identity(code: "identity_error", context: "restore Pubky grant session from platform provider") + + XCTAssertTrue(PaykitSdkService.shouldDeferStaleSession(error: error, hasStoredSession: true)) } func testMissingSessionOrUnrelatedIdentityFailureIsNotDeferred() { From 2834e7a151a0a95e7321c0926951f7a12d072830 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 2 Sep 2026 17:21:59 -0500 Subject: [PATCH 17/40] feat: support Pubky Ring signup --- Bitkit/AppScene.swift | 4 + Bitkit/MainNavView.swift | 13 ++ Bitkit/Managers/PubkyProfileManager.swift | 129 ++++++++++++++---- Bitkit/Models/PubkyAuthRequest.swift | 125 ++++++++++++++++- .../Localization/en.lproj/Localizable.strings | 1 + Bitkit/Services/PubkyService.swift | 34 +++++ Bitkit/Utilities/ShopPaymentRequest.swift | 1 + Bitkit/ViewModels/AppViewModel.swift | 72 ++++++++-- Bitkit/ViewModels/SheetViewModel.swift | 4 +- .../PubkyAuthApprovalSheet.swift | 30 +++- BitkitTests/PubkyAuthRequestTests.swift | 42 +++++- BitkitTests/ShopPaymentRequestTests.swift | 38 +++++- changelog.d/next/724.added.md | 1 + 13 files changed, 436 insertions(+), 58 deletions(-) create mode 100644 changelog.d/next/724.added.md diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 76754898e..88067cd67 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -878,6 +878,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..20808a331 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -25,6 +25,15 @@ struct MainNavView: View { PaykitFeatureFlags.isUIAvailable && isPaykitUIEnabled } + private var shouldResumePendingPubkyProfileSetup: Bool { + isPaykitUIActive && + pubkyProfile.isProfileSetupPending && + pubkyProfile.isAuthenticated && + sheets.activeSheetConfiguration == nil && + !sheets.isReplacingSheet && + navigation.currentRoute != .createProfile + } + // 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 +48,10 @@ struct MainNavView: View { navigation.navigate(.spendingHwSigned) } } + .onChange(of: shouldResumePendingPubkyProfileSetup, initial: true) { _, shouldResume in + guard shouldResume else { return } + navigation.navigate(.createProfile) + } .sheet( item: $sheets.addTagSheetItem, onDismiss: { diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 2c0001981..e9d1a602b 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -121,6 +121,10 @@ private enum PubkyProfileManagerError: LocalizedError { } } +enum PubkySignupError: Error { + case alreadySignedIn +} + @MainActor class PubkyProfileManager: ObservableObject { enum SessionInitializationResult: Equatable { @@ -138,12 +142,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 @@ -252,6 +258,22 @@ class PubkyProfileManager: ObservableObject { existingImageUrl: String? = nil, avatarImage: UIImage? = nil ) async throws { + if isProfileSetupPending { + guard let publicKey else { + throw PubkyServiceError.sessionNotActive + } + try await createProfile( + publicKey: publicKey, + name: name, + bio: bio, + links: links, + tags: tags, + existingImageUrl: existingImageUrl, + avatarImage: avatarImage + ) + return + } + let (publicKeyZ32, secretKeyHex) = try await deriveKeys() _ = try await Task.detached { @@ -279,35 +301,15 @@ class PubkyProfileManager: ObservableObject { }.value do { - var avatarUri: String? - if let avatarImage { - avatarUri = try await uploadAvatar(image: avatarImage) - } - let resolvedImageUrl = Self.resolvedImageUrl(newImageUrl: avatarUri, existingImageUrl: existingImageUrl) - - try await writeProfile( - name: name, - bio: bio, - imageUrl: resolvedImageUrl, - links: links, - tags: tags - ) - Self.notifyAppStateBackupChanged() - - let createdProfile = PubkyProfile( + try await createProfile( publicKey: publicKeyZ32, name: name, bio: bio, - imageUrl: resolvedImageUrl, links: links, tags: tags, - status: nil + existingImageUrl: existingImageUrl, + avatarImage: avatarImage ) - - publicKey = publicKeyZ32 - authState = .authenticated - profile = createdProfile - cacheProfileMetadata(createdProfile) } catch { let profileCreationError = error await discardAbandonedSession() @@ -317,6 +319,70 @@ class PubkyProfileManager: ObservableObject { Logger.info("Pubky identity created for \(publicKeyZ32)", context: "PubkyProfileManager") } + 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) + + try await writeProfile(name: name, bio: bio, imageUrl: imageUrl, links: links, tags: tags) + Self.notifyAppStateBackupChanged() + + 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.isRingSignup, + 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 + } + + try await PubkyService.registerIdentity( + secretKeyHex: secretKeyHex, + homeserverZ32: homeserver, + signupCode: request.signupToken + ) + try await PubkyService.approveRingAuth(authUrl: request.authorizationUrl, secretKeyHex: secretKeyHex) + setProfileSetupPending(true) + _ = try await PubkyService.signIn(secretKeyHex: secretKeyHex) + + UserDefaults.standard.set(false, forKey: PrivatePaykitService.publishingEnabledKey) + Self.notifyAppStateBackupChanged() + self.publicKey = publicKey + authState = .authenticated + } + func saveProfile( name: String, bio: String, @@ -736,6 +802,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 +896,7 @@ class PubkyProfileManager: ObservableObject { throw error } + setProfileSetupPending(false) clearAuthenticatedState() } @@ -871,6 +939,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 +963,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 +994,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..b41a94430 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,90 @@ struct PubkyAuthRequest { let permissions: [PubkyAuthPermission] let serviceNames: [String] let bitkitClaim: PubkyAuthClaim? + let homeserverPublicKey: String? + let signupToken: String? + let authorizationUrl: String + + var isRingSignup: Bool { + guard let components = URLComponents(string: rawUrl) else { return false } + return components.scheme?.lowercased() == "pubkyring" && components.host?.lowercased() == "signup" + } 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), + components.scheme?.lowercased() == "pubkyring", + components.host?.lowercased() == "signup" + { + return try parseRingSignup(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 + ) + } + + private static func parseRingSignup(url: String, components: URLComponents) throws -> PubkyAuthRequest { + let values = Dictionary(grouping: components.queryItems ?? [], by: \.name) + let relay = try requiredQueryValue("relay", from: values) + let secret = try requiredQueryValue("secret", from: values) + let capabilities = try requiredQueryValue("caps", from: values) + let homeserver = try requiredQueryValue("hs", from: values) + let authorizationUrl = ringAuthorizationUrl(relay: relay, secret: secret, capabilities: capabilities) + do { + _ = 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? = nil + ) throws -> PubkyAuthRequest { let permissions = parseCapabilities(capabilities) var seenServiceNames = Set() let serviceNames = permissions @@ -75,16 +152,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 ?? url ) } + 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..889472e6c 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -710,6 +710,7 @@ "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"; diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index da4d702ab..25a03733c 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,18 @@ enum PubkyService { return result.sessionAccess.exportSessionSecret() } + static func registerIdentity( + secretKeyHex: String, + homeserverZ32: String, + signupCode: String? = nil + ) async throws { + try await PaykitSdkService.shared.registerIdentity( + secretKeyHex: secretKeyHex, + homeserverPublicKey: homeserverZ32, + signupCode: signupCode + ) + } + /// 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 +412,22 @@ actor PaykitSdkService { } } + func registerIdentity( + secretKeyHex: String, + homeserverPublicKey: String, + signupCode: String? + ) async throws { + try await operationLock.withLock { + _ = try await bootstrap().signUp( + localSecretKey: Self.localSecretKey(fromHex: secretKeyHex), + receiverNoiseSecretKey: sessionProvider.loadOrDeriveReceiverNoiseSecretKey(), + homeserverPublicKey: homeserverPublicKey, + signupCode: signupCode, + requiredCapabilities: Self.requiredCapabilities() + ) + } + } + 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..dd5d02fb4 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -437,8 +437,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 +492,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 + } + handlePubkyAuthApproval(uri) + return + } + let data: BitkitCore.Scanner if let prevalidatedPaymentRequest { data = prevalidatedPaymentRequest @@ -787,13 +811,41 @@ extension AppViewModel { } private func handlePubkyAuthApproval(_ authUrl: String) { - // State 1: No Pubky identity at all - guard (try? Keychain.loadString(key: .paykitSession))?.isEmpty == false else { + let request: PubkyAuthRequest + + do { + request = try PubkyAuthRequest.parse(url: authUrl) + } catch { + Logger.error("Failed to parse pubky auth URL: \(error)", context: "AppViewModel") + toast(type: .error, title: t("pubky_auth__invalid_request")) + return + } + + if request.isRingSignup { + do { + guard try !PubkyProfileManager.hasStoredIdentity() else { + toast(type: .info, title: t("pubky_auth__already_signed_in")) + return + } + } catch { + Logger.error("Failed to read stored Pubky identity: \(error)", context: "AppViewModel") + 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 { 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 { @@ -801,14 +853,7 @@ extension AppViewModel { 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 +871,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/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift index c7842ee46..26098295c 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 } @@ -231,10 +229,14 @@ struct PubkyAuthApprovalSheet: View { descriptionText .padding(.bottom, 8) - BodySText(t("pubky_auth__requester", variables: ["clientId": config.request.clientID])) - .lineLimit(1) - .truncationMode(.tail) - .padding(.bottom, 32) + 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) + } permissionsSection @@ -379,6 +381,15 @@ struct PubkyAuthApprovalSheet: View { private func performAuthorization() async { guard state == .authorizing else { return } do { + if config.request.isRingSignup { + 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 +400,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/PubkyAuthRequestTests.swift b/BitkitTests/PubkyAuthRequestTests.swift index cb4cb80a2..de81be8b2 100644 --- a/BitkitTests/PubkyAuthRequestTests.swift +++ b/BitkitTests/PubkyAuthRequestTests.swift @@ -5,21 +5,50 @@ 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())) XCTAssertFalse(PubkyAuthRequest.isProtocolURL("lightning:lnbc1example")) } + func testParseRingSignup() throws { + let request = try PubkyAuthRequest.parse(url: ringSignupUrl(signupToken: "invite code")) + + XCTAssertTrue(request.isRingSignup) + 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 testParseRingSignupRejectsMissingOrDuplicateRequiredValues() { + let invalidUrls = [ + ringSignupUrl().replacingOccurrences(of: "&secret=\(secret)", with: ""), + "\(ringSignupUrl())&hs=other", + ] + + 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.isRingSignup) XCTAssertEqual(request.clientID, "paykit.test") XCTAssertEqual(request.capabilities, capabilities) XCTAssertEqual(request.permissions.count, 1) @@ -262,6 +291,15 @@ 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) -> String { + let token = signupToken.map { + "&st=\($0.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? $0)" + } ?? "" + return "pubkyring://signup?hs=\(publicKey)" + + "&relay=https%3A%2F%2Frelay.example%2Finbox%2F" + + "&secret=\(secret)&caps=%2Fpub%2Fexample.app%2F%3Arw\(token)" } } diff --git a/BitkitTests/ShopPaymentRequestTests.swift b/BitkitTests/ShopPaymentRequestTests.swift index a3304d82f..fd4b1ae31 100644 --- a/BitkitTests/ShopPaymentRequestTests.swift +++ b/BitkitTests/ShopPaymentRequestTests.swift @@ -18,20 +18,39 @@ 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, + ] + + 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) } @@ -49,6 +68,13 @@ 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 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..8aab2c4bb --- /dev/null +++ b/changelog.d/next/724.added.md @@ -0,0 +1 @@ +Added support for creating a Pubky identity from Pubky Ring signup requests. From 027f28abaf52ab451b012a1384697d191c9983f9 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 2 Sep 2026 17:35:05 -0500 Subject: [PATCH 18/40] fix: complete Pubky Ring signup --- Bitkit/MainNavView.swift | 33 ++++++++++++++++------- Bitkit/Managers/PubkyProfileManager.swift | 4 +-- Bitkit/Services/PubkyService.swift | 18 ++++++++++--- 3 files changed, 41 insertions(+), 14 deletions(-) diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 20808a331..91b9355cf 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -1,6 +1,12 @@ import SwiftUI struct MainNavView: View { + private enum PendingProfileSetupResumeState { + case inactive + case waiting + case ready + } + @AppStorage(PaykitFeatureFlags.uiEnabledKey) private var isPaykitUIEnabled = false @EnvironmentObject private var app: AppViewModel @@ -20,18 +26,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 shouldResumePendingPubkyProfileSetup: Bool { - isPaykitUIActive && - pubkyProfile.isProfileSetupPending && - pubkyProfile.isAuthenticated && - sheets.activeSheetConfiguration == nil && - !sheets.isReplacingSheet && - navigation.currentRoute != .createProfile + private var pendingProfileSetupResumeState: PendingProfileSetupResumeState { + guard pubkyProfile.isProfileSetupPending else { return .inactive } + guard isPaykitUIActive, + pubkyProfile.isAuthenticated, + sheets.activeSheetConfiguration == nil, + !sheets.isReplacingSheet, + navigation.currentRoute != .createProfile + else { + return .waiting + } + return .ready } // Delay constants for clipboard processing @@ -48,8 +59,12 @@ struct MainNavView: View { navigation.navigate(.spendingHwSigned) } } - .onChange(of: shouldResumePendingPubkyProfileSetup, initial: true) { _, shouldResume in - guard shouldResume else { return } + .onChange(of: pendingProfileSetupResumeState, initial: true) { _, resumeState in + if resumeState == .inactive { + didResumePendingPubkyProfileSetup = false + } + guard resumeState == .ready, !didResumePendingPubkyProfileSetup else { return } + didResumePendingPubkyProfileSetup = true navigation.navigate(.createProfile) } .sheet( diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index e9d1a602b..486e13dd6 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -368,14 +368,14 @@ class PubkyProfileManager: ObservableObject { throw PubkySignupError.alreadySignedIn } - try await PubkyService.registerIdentity( + let registeredSession = try await PubkyService.registerIdentity( secretKeyHex: secretKeyHex, homeserverZ32: homeserver, signupCode: request.signupToken ) try await PubkyService.approveRingAuth(authUrl: request.authorizationUrl, secretKeyHex: secretKeyHex) setProfileSetupPending(true) - _ = try await PubkyService.signIn(secretKeyHex: secretKeyHex) + try await PubkyService.activateRegisteredIdentity(registeredSession) UserDefaults.standard.set(false, forKey: PrivatePaykitService.publishingEnabledKey) Self.notifyAppStateBackupChanged() diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 25a03733c..4096f7550 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -234,7 +234,7 @@ enum PubkyService { secretKeyHex: String, homeserverZ32: String, signupCode: String? = nil - ) async throws { + ) async throws -> PubkySessionBootstrapResult { try await PaykitSdkService.shared.registerIdentity( secretKeyHex: secretKeyHex, homeserverPublicKey: homeserverZ32, @@ -242,6 +242,10 @@ enum PubkyService { ) } + 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) @@ -416,9 +420,9 @@ actor PaykitSdkService { secretKeyHex: String, homeserverPublicKey: String, signupCode: String? - ) async throws { + ) async throws -> PubkySessionBootstrapResult { try await operationLock.withLock { - _ = try await bootstrap().signUp( + try await bootstrap().signUp( localSecretKey: Self.localSecretKey(fromHex: secretKeyHex), receiverNoiseSecretKey: sessionProvider.loadOrDeriveReceiverNoiseSecretKey(), homeserverPublicKey: homeserverPublicKey, @@ -428,6 +432,14 @@ actor PaykitSdkService { } } + func activateRegisteredIdentity(_ result: PubkySessionBootstrapResult) async throws { + try await operationLock.withLock { + let previousPublicKey = await currentSdkStatePublicKey() + try await activateBootstrapResult(result, previousPublicKey: previousPublicKey, shouldStoreLocalSecret: true) + markWalletBackupDataChanged() + } + } + func signIn(secretKeyHex: String) async throws -> PubkySessionBootstrapResult { try await operationLock.withLock { let previousPublicKey = await currentSdkStatePublicKey() From 86e8068d8fe7cefaeaa29896eb5410455f6df5b0 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 2 Sep 2026 17:56:21 -0500 Subject: [PATCH 19/40] feat: support direct Pubky signup --- Bitkit/AppScene.swift | 10 +++- Bitkit/Managers/PubkyProfileManager.swift | 8 +-- Bitkit/Models/PubkyAuthRequest.swift | 54 ++++++++++++------- Bitkit/ViewModels/AppViewModel.swift | 32 ++++++++--- .../PubkyAuthApprovalSheet.swift | 2 +- BitkitTests/PubkyAuthRequestTests.swift | 26 ++++++++- BitkitTests/ShopPaymentRequestTests.swift | 5 ++ changelog.d/next/724.added.md | 2 +- 8 files changed, 105 insertions(+), 34 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 88067cd67..f0315347e 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -31,7 +31,7 @@ struct AppScene: View { @StateObject private var channelDetails = ChannelDetailsViewModel.shared @StateObject private var migrations = MigrationsService.shared @StateObject private var languageManager = LanguageManager.shared - @StateObject private var pubkyProfile = PubkyProfileManager() + @StateObject private var pubkyProfile: PubkyProfileManager @StateObject private var contactsManager = ContactsManager() @State private var keyboardManager = KeyboardManager() @State private var trezorManager: TrezorManager @@ -56,6 +56,7 @@ struct AppScene: View { init() { let sheetViewModel = SheetViewModel() let navigationViewModel = NavigationViewModel() + let pubkyProfile = PubkyProfileManager() let transferService = TransferService( lightningService: LightningService.shared, blocktankService: CoreService.shared.blocktank @@ -66,9 +67,14 @@ struct AppScene: View { PaykitFeatureFlags.enforceBuildAvailability() ContactPaymentsService.enableAllPaymentOptions() - _app = StateObject(wrappedValue: AppViewModel(sheetViewModel: sheetViewModel, navigationViewModel: navigationViewModel)) + _app = StateObject(wrappedValue: AppViewModel( + sheetViewModel: sheetViewModel, + navigationViewModel: navigationViewModel, + pubkyProfile: pubkyProfile + )) _sheets = StateObject(wrappedValue: sheetViewModel) _navigation = StateObject(wrappedValue: navigationViewModel) + _pubkyProfile = StateObject(wrappedValue: pubkyProfile) let feeEstimatesManager = FeeEstimatesManager() let walletVm = WalletViewModel( transferService: transferService, diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 486e13dd6..c3be6607f 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -354,9 +354,7 @@ class PubkyProfileManager: ObservableObject { } func approveSignupAuth(request: PubkyAuthRequest) async throws { - guard request.isRingSignup, - let homeserver = request.homeserverPublicKey - else { + guard request.isSignup, let homeserver = request.homeserverPublicKey else { throw PubkyServiceError.invalidAuthUrl } guard publicKey == nil, try !Self.hasStoredIdentity() else { @@ -373,7 +371,9 @@ class PubkyProfileManager: ObservableObject { homeserverZ32: homeserver, signupCode: request.signupToken ) - try await PubkyService.approveRingAuth(authUrl: request.authorizationUrl, secretKeyHex: secretKeyHex) + if let authorizationUrl = request.authorizationUrl { + try await PubkyService.approveRingAuth(authUrl: authorizationUrl, secretKeyHex: secretKeyHex) + } setProfileSetupPending(true) try await PubkyService.activateRegisteredIdentity(registeredSession) diff --git a/Bitkit/Models/PubkyAuthRequest.swift b/Bitkit/Models/PubkyAuthRequest.swift index b41a94430..e6a90f441 100644 --- a/Bitkit/Models/PubkyAuthRequest.swift +++ b/Bitkit/Models/PubkyAuthRequest.swift @@ -62,11 +62,10 @@ struct PubkyAuthRequest { let bitkitClaim: PubkyAuthClaim? let homeserverPublicKey: String? let signupToken: String? - let authorizationUrl: String + let authorizationUrl: String? - var isRingSignup: Bool { - guard let components = URLComponents(string: rawUrl) else { return false } - return components.scheme?.lowercased() == "pubkyring" && components.host?.lowercased() == "signup" + var isSignup: Bool { + Self.isSignupURL(rawUrl) } static func isProtocolURL(_ value: String) -> Bool { @@ -85,11 +84,8 @@ struct PubkyAuthRequest { } static func parse(url: String) throws -> PubkyAuthRequest { - if let components = URLComponents(string: url), - components.scheme?.lowercased() == "pubkyring", - components.host?.lowercased() == "signup" - { - return try parseRingSignup(url: url, components: components) + if let components = URLComponents(string: url), isSignupURL(components) { + return try parseSignup(url: url, components: components) } let details = try Paykit.parsePubkyAuthUrl(authUrl: url) @@ -101,19 +97,41 @@ struct PubkyAuthRequest { relay: details.relayUrl, capabilities: capabilities, homeserverPublicKey: nil, - signupToken: nil + signupToken: nil, + authorizationUrl: url ) } - private static func parseRingSignup(url: String, components: URLComponents) throws -> PubkyAuthRequest { + 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 relay = try requiredQueryValue("relay", from: values) - let secret = try requiredQueryValue("secret", from: values) - let capabilities = try requiredQueryValue("caps", from: values) let homeserver = try requiredQueryValue("hs", from: values) - let authorizationUrl = ringAuthorizationUrl(relay: relay, secret: secret, capabilities: capabilities) + let authorizesApp = components.scheme?.lowercased() == "pubkyring" + 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 { - _ = try BitkitCore.parsePubkyAuthUrl(authUrl: authorizationUrl) + if let authorizationUrl { + _ = try BitkitCore.parsePubkyAuthUrl(authUrl: authorizationUrl) + } _ = try Paykit.normalizePubkyPublicKey(value: homeserver) } catch { throw PubkyAuthRequestError.invalidUrl @@ -142,7 +160,7 @@ struct PubkyAuthRequest { capabilities: String, homeserverPublicKey: String?, signupToken: String?, - authorizationUrl: String? = nil + authorizationUrl: String? ) throws -> PubkyAuthRequest { let permissions = parseCapabilities(capabilities) var seenServiceNames = Set() @@ -161,7 +179,7 @@ struct PubkyAuthRequest { bitkitClaim: bitkitClaim, homeserverPublicKey: homeserverPublicKey, signupToken: signupToken, - authorizationUrl: authorizationUrl ?? url + authorizationUrl: authorizationUrl ) } diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index dd5d02fb4..1c091a41c 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -119,6 +119,7 @@ class AppViewModel: ObservableObject { private let coreService: CoreService private let sheetViewModel: SheetViewModel private let navigationViewModel: NavigationViewModel + private let pubkyProfile: PubkyProfileManager private var scannedDataHandlingId: UUID? private var manualEntryValidationSequence: UInt64 = 0 @@ -130,12 +131,14 @@ class AppViewModel: ObservableObject { lightningService: LightningService = .shared, coreService: CoreService = .shared, sheetViewModel: SheetViewModel, - navigationViewModel: NavigationViewModel + navigationViewModel: NavigationViewModel, + pubkyProfile: PubkyProfileManager ) { self.lightningService = lightningService self.coreService = coreService self.sheetViewModel = sheetViewModel self.navigationViewModel = navigationViewModel + self.pubkyProfile = pubkyProfile setupManualEntryValidationDebounce() @@ -245,7 +248,11 @@ class AppViewModel: ObservableObject { /// Convenience initializer for previews and testing convenience init() { - self.init(sheetViewModel: SheetViewModel(), navigationViewModel: NavigationViewModel()) + self.init( + sheetViewModel: SheetViewModel(), + navigationViewModel: NavigationViewModel(), + pubkyProfile: PubkyProfileManager() + ) } deinit {} @@ -505,7 +512,7 @@ extension AppViewModel { ) return } - handlePubkyAuthApproval(uri) + await handlePubkyAuthApproval(uri) return } @@ -683,7 +690,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: @@ -810,7 +817,7 @@ extension AppViewModel { sheetViewModel.showSheet(.lnurlAuth, data: LnurlAuthConfig(lnurl: lnurl, authData: data)) } - private func handlePubkyAuthApproval(_ authUrl: String) { + private func handlePubkyAuthApproval(_ authUrl: String) async { let request: PubkyAuthRequest do { @@ -821,7 +828,7 @@ extension AppViewModel { return } - if request.isRingSignup { + if request.isSignup { do { guard try !PubkyProfileManager.hasStoredIdentity() else { toast(type: .info, title: t("pubky_auth__already_signed_in")) @@ -833,6 +840,19 @@ extension AppViewModel { return } + if request.authorizationUrl == nil { + sheetViewModel.hideSheet() + do { + try await pubkyProfile.approveSignupAuth(request: request) + } catch PubkySignupError.alreadySignedIn { + toast(type: .info, title: t("pubky_auth__already_signed_in")) + } catch { + Logger.error("Failed to complete direct Pubky signup: \(error)", context: "AppViewModel") + toast(type: .error, title: t("pubky_auth__approval_failed"), description: error.localizedDescription) + } + return + } + sheetViewModel.showSheet( .pubkyAuthApproval, data: PubkyAuthApprovalConfig(request: request) diff --git a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift index 26098295c..2dc3749ef 100644 --- a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift +++ b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift @@ -381,7 +381,7 @@ struct PubkyAuthApprovalSheet: View { private func performAuthorization() async { guard state == .authorizing else { return } do { - if config.request.isRingSignup { + if config.request.isSignup { try await pubkyProfile.approveSignupAuth(request: config.request) guard sheets.pubkyAuthApprovalSheetItem?.request.rawUrl == config.request.rawUrl else { return diff --git a/BitkitTests/PubkyAuthRequestTests.swift b/BitkitTests/PubkyAuthRequestTests.swift index de81be8b2..4e7b48a8a 100644 --- a/BitkitTests/PubkyAuthRequestTests.swift +++ b/BitkitTests/PubkyAuthRequestTests.swift @@ -12,13 +12,14 @@ final class PubkyAuthRequestTests: XCTestCase { 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 testParseRingSignup() throws { let request = try PubkyAuthRequest.parse(url: ringSignupUrl(signupToken: "invite code")) - XCTAssertTrue(request.isRingSignup) + XCTAssertTrue(request.isSignup) XCTAssertEqual(request.kind, .signUp) XCTAssertEqual(request.homeserverPublicKey, publicKey) XCTAssertEqual(request.signupToken, "invite code") @@ -31,6 +32,20 @@ final class PubkyAuthRequestTests: XCTestCase { ) } + 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: ""), @@ -48,7 +63,7 @@ final class PubkyAuthRequestTests: XCTestCase { let request = try PubkyAuthRequest.parse(url: url) - XCTAssertFalse(request.isRingSignup) + XCTAssertFalse(request.isSignup) XCTAssertEqual(request.clientID, "paykit.test") XCTAssertEqual(request.capabilities, capabilities) XCTAssertEqual(request.permissions.count, 1) @@ -302,4 +317,11 @@ final class PubkyAuthRequestTests: XCTestCase { "&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/ShopPaymentRequestTests.swift b/BitkitTests/ShopPaymentRequestTests.swift index fd4b1ae31..07738e6a7 100644 --- a/BitkitTests/ShopPaymentRequestTests.swift +++ b/BitkitTests/ShopPaymentRequestTests.swift @@ -23,6 +23,7 @@ final class ShopPaymentRequestTests: XCTestCase { let requests = [ "https://btcpay.example/plugins/store123/samrock/protocol?setup=btc-chain&otp=abc123", pubkySignupUrl, + directPubkySignupUrl, ] for request in requests { @@ -75,6 +76,10 @@ final class ShopPaymentRequestTests: XCTestCase { "&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 index 8aab2c4bb..fe1fcd278 100644 --- a/changelog.d/next/724.added.md +++ b/changelog.d/next/724.added.md @@ -1 +1 @@ -Added support for creating a Pubky identity from Pubky Ring signup requests. +Added support for creating a Pubky identity from app-authorized and direct Pubky signup requests. From 00d8cae81b5b298c50beb8af02c30d3323e00aaf Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 3 Sep 2026 07:46:38 -0500 Subject: [PATCH 20/40] fix: complete Pubky signup handoff --- Bitkit/MainNavView.swift | 13 +++++++++ Bitkit/Models/PubkyAuthRequest.swift | 7 ++++- Bitkit/ViewModels/AppViewModel.swift | 8 ++++++ BitkitTests/PubkyAuthRequestTests.swift | 35 ++++++++++++++----------- 4 files changed, 46 insertions(+), 17 deletions(-) diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 91b9355cf..ab5508d54 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -422,6 +422,19 @@ struct MainNavView: View { } message: { Text(t("other__clipboard_redirect_msg")) } + .overlay { + if app.isCompletingPubkySignup { + ZStack { + Color.black.ignoresSafeArea() + + HStack(spacing: 12) { + ActivityIndicator(size: 20) + BodyMText(t("profile__deriving_keys"), textColor: .white64) + } + } + .accessibilityIdentifier("PubkySignupLoading") + } + } } // MARK: - Loading View diff --git a/Bitkit/Models/PubkyAuthRequest.swift b/Bitkit/Models/PubkyAuthRequest.swift index e6a90f441..2758c7bf8 100644 --- a/Bitkit/Models/PubkyAuthRequest.swift +++ b/Bitkit/Models/PubkyAuthRequest.swift @@ -121,7 +121,12 @@ struct PubkyAuthRequest { 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" + 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) : "" diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 1c091a41c..f146861c8 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -66,6 +66,7 @@ class AppViewModel: ObservableObject { // LNURL @Published var lnurlPayData: LnurlPayData? @Published var lnurlWithdrawData: LnurlWithdrawData? + @Published private(set) var isCompletingPubkySignup = false // Onboarding @AppStorage("hasDismissedWidgetsOnboardingHint") var hasDismissedWidgetsOnboardingHint: Bool = false @@ -824,6 +825,7 @@ extension AppViewModel { 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 } @@ -831,17 +833,21 @@ extension AppViewModel { 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 } if request.authorizationUrl == nil { sheetViewModel.hideSheet() + isCompletingPubkySignup = true + defer { isCompletingPubkySignup = false } do { try await pubkyProfile.approveSignupAuth(request: request) } catch PubkySignupError.alreadySignedIn { @@ -862,6 +868,7 @@ extension AppViewModel { 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 } @@ -869,6 +876,7 @@ extension AppViewModel { 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 } diff --git a/BitkitTests/PubkyAuthRequestTests.swift b/BitkitTests/PubkyAuthRequestTests.swift index 4e7b48a8a..11fab1bb8 100644 --- a/BitkitTests/PubkyAuthRequestTests.swift +++ b/BitkitTests/PubkyAuthRequestTests.swift @@ -16,20 +16,22 @@ final class PubkyAuthRequestTests: XCTestCase { XCTAssertFalse(PubkyAuthRequest.isProtocolURL("lightning:lnbc1example")) } - func testParseRingSignup() throws { - let request = try PubkyAuthRequest.parse(url: ringSignupUrl(signupToken: "invite code")) - - 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 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 { @@ -50,6 +52,7 @@ final class PubkyAuthRequestTests: XCTestCase { let invalidUrls = [ ringSignupUrl().replacingOccurrences(of: "&secret=\(secret)", with: ""), "\(ringSignupUrl())&hs=other", + directSignupUrl(action: "signup") + "&relay=https%3A%2F%2Frelay.example", ] for url in invalidUrls { @@ -309,11 +312,11 @@ final class PubkyAuthRequestTests: XCTestCase { "&cid=paykit.test&cpk=\(publicKey)\(claims)" } - private func ringSignupUrl(signupToken: String? = nil) -> String { + private func ringSignupUrl(signupToken: String? = nil, scheme: String = "pubkyring") -> String { let token = signupToken.map { "&st=\($0.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? $0)" } ?? "" - return "pubkyring://signup?hs=\(publicKey)" + + return "\(scheme)://signup?hs=\(publicKey)" + "&relay=https%3A%2F%2Frelay.example%2Finbox%2F" + "&secret=\(secret)&caps=%2Fpub%2Fexample.app%2F%3Arw\(token)" } From 1dcc1cef7e2b84eefc69f5a3bd0616a00d718c51 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 3 Sep 2026 17:12:10 -0500 Subject: [PATCH 21/40] fix: recover failed pubky signup --- Bitkit/Managers/PubkyProfileManager.swift | 11 ++++++++--- Bitkit/Services/PubkyService.swift | 10 +++++++++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index c3be6607f..962885777 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -374,13 +374,18 @@ class PubkyProfileManager: ObservableObject { if let authorizationUrl = request.authorizationUrl { try await PubkyService.approveRingAuth(authUrl: authorizationUrl, secretKeyHex: secretKeyHex) } - setProfileSetupPending(true) - try await PubkyService.activateRegisteredIdentity(registeredSession) + do { + try await PubkyService.activateRegisteredIdentity(registeredSession) + } catch { + setProfileSetupPending(false) + throw error + } UserDefaults.standard.set(false, forKey: PrivatePaykitService.publishingEnabledKey) - Self.notifyAppStateBackupChanged() self.publicKey = publicKey authState = .authenticated + setProfileSetupPending(true) + Self.notifyAppStateBackupChanged() } func saveProfile( diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 4096f7550..d1524862f 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -435,7 +435,15 @@ actor PaykitSdkService { func activateRegisteredIdentity(_ result: PubkySessionBootstrapResult) async throws { try await operationLock.withLock { let previousPublicKey = await currentSdkStatePublicKey() - try await activateBootstrapResult(result, previousPublicKey: previousPublicKey, shouldStoreLocalSecret: true) + do { + try await activateBootstrapResult(result, previousPublicKey: previousPublicKey, shouldStoreLocalSecret: true) + } catch { + try? sessionProvider.clearSessionAccess() + try? Keychain.delete(key: .paykitSdkState) + resetRuntime() + markWalletBackupDataChanged() + throw error + } markWalletBackupDataChanged() } } From f1a6adfd86f217d300cbd3b979e50243f7f9f9d3 Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 6 Sep 2026 17:01:15 +0200 Subject: [PATCH 22/40] fix: recover and verify pubky signup state --- .../PubkyKeyDerivationLoadingView.swift | 11 +++ Bitkit/MainNavView.swift | 5 +- Bitkit/Managers/PubkyProfileManager.swift | 61 +++++++++++---- Bitkit/Views/Profile/CreateProfileView.swift | 8 +- BitkitTests/PubkyProfileManagerTests.swift | 78 +++++++++++++++++++ 5 files changed, 135 insertions(+), 28 deletions(-) create mode 100644 Bitkit/Components/PubkyKeyDerivationLoadingView.swift diff --git a/Bitkit/Components/PubkyKeyDerivationLoadingView.swift b/Bitkit/Components/PubkyKeyDerivationLoadingView.swift new file mode 100644 index 000000000..7d60323d2 --- /dev/null +++ b/Bitkit/Components/PubkyKeyDerivationLoadingView.swift @@ -0,0 +1,11 @@ +import SwiftUI + +struct PubkyKeyDerivationLoadingView: View { + var body: some View { + VStack(spacing: 12) { + ActivityIndicator(size: 32) + BodyMText(t("profile__deriving_keys"), textColor: .white64) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index ab5508d54..32ee9da05 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -427,10 +427,7 @@ struct MainNavView: View { ZStack { Color.black.ignoresSafeArea() - HStack(spacing: 12) { - ActivityIndicator(size: 20) - BodyMText(t("profile__deriving_keys"), textColor: .white64) - } + PubkyKeyDerivationLoadingView() } .accessibilityIdentifier("PubkySignupLoading") } diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 962885777..b9b51407e 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 { @@ -258,10 +259,7 @@ class PubkyProfileManager: ObservableObject { existingImageUrl: String? = nil, avatarImage: UIImage? = nil ) async throws { - if isProfileSetupPending { - guard let publicKey else { - throw PubkyServiceError.sessionNotActive - } + if isProfileSetupPending, let publicKey { try await createProfile( publicKey: publicKey, name: name, @@ -274,6 +272,7 @@ class PubkyProfileManager: ObservableObject { return } + setProfileSetupPending(false) let (publicKeyZ32, secretKeyHex) = try await deriveKeys() _ = try await Task.detached { @@ -366,20 +365,34 @@ class PubkyProfileManager: ObservableObject { throw PubkySignupError.alreadySignedIn } - let registeredSession = try await PubkyService.registerIdentity( - secretKeyHex: secretKeyHex, - homeserverZ32: homeserver, - signupCode: request.signupToken + 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) } ) - if let authorizationUrl = request.authorizationUrl { - try await PubkyService.approveRingAuth(authUrl: authorizationUrl, secretKeyHex: secretKeyHex) - } - do { - try await PubkyService.activateRegisteredIdentity(registeredSession) - } catch { - setProfileSetupPending(false) - throw error - } + } + + 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 @@ -719,6 +732,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 } diff --git a/Bitkit/Views/Profile/CreateProfileView.swift b/Bitkit/Views/Profile/CreateProfileView.swift index a0a848db9..9b3e84cf4 100644 --- a/Bitkit/Views/Profile/CreateProfileView.swift +++ b/Bitkit/Views/Profile/CreateProfileView.swift @@ -145,13 +145,7 @@ struct CreateProfileView: View { // MARK: - Loading 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) + PubkyKeyDerivationLoadingView() } // MARK: - Image Selection diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index 77ebf71fc..4ebc8d6b5 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -1,7 +1,75 @@ @testable import Bitkit +import class Paykit.PubkySessionAccess +import struct Paykit.PubkySessionBootstrapResult import XCTest final class PubkyProfileManagerTests: XCTestCase { + @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: []) + 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 +929,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, From 9320e11b8a43d1a724133c27ea2f35800f380408 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 7 Sep 2026 20:14:06 +0300 Subject: [PATCH 23/40] fix: require consent for every pubky signup --- .../PubkyKeyDerivationLoadingView.swift | 11 ------- Bitkit/MainNavView.swift | 10 ------ .../Localization/en.lproj/Localizable.strings | 2 ++ Bitkit/ViewModels/AppViewModel.swift | 16 ---------- Bitkit/Views/Profile/CreateProfileView.swift | 6 +++- .../PubkyAuthApprovalSheet.swift | 32 ++++++++++++++++--- BitkitTests/ShopPaymentRequestTests.swift | 25 +++++++++++++++ 7 files changed, 60 insertions(+), 42 deletions(-) delete mode 100644 Bitkit/Components/PubkyKeyDerivationLoadingView.swift diff --git a/Bitkit/Components/PubkyKeyDerivationLoadingView.swift b/Bitkit/Components/PubkyKeyDerivationLoadingView.swift deleted file mode 100644 index 7d60323d2..000000000 --- a/Bitkit/Components/PubkyKeyDerivationLoadingView.swift +++ /dev/null @@ -1,11 +0,0 @@ -import SwiftUI - -struct PubkyKeyDerivationLoadingView: View { - var body: some View { - VStack(spacing: 12) { - ActivityIndicator(size: 32) - BodyMText(t("profile__deriving_keys"), textColor: .white64) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } -} diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 32ee9da05..91b9355cf 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -422,16 +422,6 @@ struct MainNavView: View { } message: { Text(t("other__clipboard_redirect_msg")) } - .overlay { - if app.isCompletingPubkySignup { - ZStack { - Color.black.ignoresSafeArea() - - PubkyKeyDerivationLoadingView() - } - .accessibilityIdentifier("PubkySignupLoading") - } - } } // MARK: - Loading View diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 889472e6c..8871ecc61 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -716,6 +716,8 @@ "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/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index f146861c8..fd2a86189 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -66,7 +66,6 @@ class AppViewModel: ObservableObject { // LNURL @Published var lnurlPayData: LnurlPayData? @Published var lnurlWithdrawData: LnurlWithdrawData? - @Published private(set) var isCompletingPubkySignup = false // Onboarding @AppStorage("hasDismissedWidgetsOnboardingHint") var hasDismissedWidgetsOnboardingHint: Bool = false @@ -844,21 +843,6 @@ extension AppViewModel { return } - if request.authorizationUrl == nil { - sheetViewModel.hideSheet() - isCompletingPubkySignup = true - defer { isCompletingPubkySignup = false } - do { - try await pubkyProfile.approveSignupAuth(request: request) - } catch PubkySignupError.alreadySignedIn { - toast(type: .info, title: t("pubky_auth__already_signed_in")) - } catch { - Logger.error("Failed to complete direct Pubky signup: \(error)", context: "AppViewModel") - toast(type: .error, title: t("pubky_auth__approval_failed"), description: error.localizedDescription) - } - return - } - sheetViewModel.showSheet( .pubkyAuthApproval, data: PubkyAuthApprovalConfig(request: request) diff --git a/Bitkit/Views/Profile/CreateProfileView.swift b/Bitkit/Views/Profile/CreateProfileView.swift index 9b3e84cf4..3081f1c7a 100644 --- a/Bitkit/Views/Profile/CreateProfileView.swift +++ b/Bitkit/Views/Profile/CreateProfileView.swift @@ -145,7 +145,11 @@ struct CreateProfileView: View { // MARK: - Loading private var loadingView: some View { - PubkyKeyDerivationLoadingView() + VStack(spacing: 12) { + ActivityIndicator(size: 32) + BodyMText(t("profile__deriving_keys"), textColor: .white64) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) } // MARK: - Image Selection diff --git a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift index 2dc3749ef..17c6179c4 100644 --- a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift +++ b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift @@ -226,8 +226,15 @@ 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) + } + + if !config.request.permissions.isEmpty { + descriptionText + .padding(.bottom, 8) + } if !config.request.clientID.isEmpty { BodySText(t("pubky_auth__requester", variables: ["clientId": config.request.clientID])) @@ -238,15 +245,32 @@ struct PubkyAuthApprovalSheet: View { Spacer().frame(height: 24) } - permissionsSection + 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) } diff --git a/BitkitTests/ShopPaymentRequestTests.swift b/BitkitTests/ShopPaymentRequestTests.swift index 07738e6a7..f1f705ec2 100644 --- a/BitkitTests/ShopPaymentRequestTests.swift +++ b/BitkitTests/ShopPaymentRequestTests.swift @@ -55,6 +55,31 @@ final class ShopPaymentRequestTests: XCTestCase { 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(), + pubkyProfile: PubkyProfileManager() + ) + + 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", From 951c10267c4bb1c5f21dbf47d692bc5b775b4ad1 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 7 Sep 2026 21:00:10 +0300 Subject: [PATCH 24/40] fix: preserve pubky homeserver on restore --- Bitkit/Managers/PubkyProfileManager.swift | 103 +++++++++++++-------- BitkitTests/PubkyProfileManagerTests.swift | 91 +++++++++++++++++- 2 files changed, 155 insertions(+), 39 deletions(-) diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index b9b51407e..6f507d27d 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -257,7 +257,10 @@ 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 { if isProfileSetupPending, let publicKey { try await createProfile( @@ -273,49 +276,73 @@ class PubkyProfileManager: ObservableObject { } setProfileSetupPending(false) - 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) - } - - var session: String - do { - session = try await PubkyService.signUp( - secretKeyHex: secretKeyHex, - homeserverZ32: signupDetails.homeserverPubky, - signupCode: signupDetails.signupCode + 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 { - try await createProfile( - publicKey: publicKeyZ32, - name: name, - bio: bio, - links: links, - tags: tags, - existingImageUrl: existingImageUrl, - avatarImage: avatarImage - ) + try await createProfile(publicKey) } catch { - let profileCreationError = error - await discardAbandonedSession() - throw profileCreationError + await discardSessionAccess() + throw error } - - Logger.info("Pubky identity created for \(publicKeyZ32)", context: "PubkyProfileManager") } private func createProfile( diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index 4ebc8d6b5..6727ac3cd 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -4,6 +4,95 @@ 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 @@ -13,7 +102,7 @@ final class PubkyProfileManagerTests: XCTestCase { let manager = KeyDerivationProbeProfileManager() do { - try await manager.createIdentity(name: "Test", bio: "", links: []) + try await manager.createIdentity(name: "Test", bio: "", links: [], loadStoredSecretKey: { nil }) XCTFail("Expected key derivation probe to stop creation") } catch { XCTAssertTrue(manager.didDeriveKeys) From 6646af9146da32874a9c58225c8aa85d62a10a3d Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 8 Sep 2026 06:39:43 +0100 Subject: [PATCH 25/40] refactor: simplify pubky profile setup wiring --- Bitkit/AppScene.swift | 7 +- Bitkit/MainNavView.swift | 58 ++++++++----- Bitkit/ViewModels/AppViewModel.swift | 8 +- .../PendingProfileSetupResumeTests.swift | 87 +++++++++++++++++++ BitkitTests/ShopPaymentRequestTests.swift | 3 +- 5 files changed, 130 insertions(+), 33 deletions(-) create mode 100644 BitkitTests/PendingProfileSetupResumeTests.swift diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index f0315347e..41a77410c 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -31,7 +31,7 @@ struct AppScene: View { @StateObject private var channelDetails = ChannelDetailsViewModel.shared @StateObject private var migrations = MigrationsService.shared @StateObject private var languageManager = LanguageManager.shared - @StateObject private var pubkyProfile: PubkyProfileManager + @StateObject private var pubkyProfile = PubkyProfileManager() @StateObject private var contactsManager = ContactsManager() @State private var keyboardManager = KeyboardManager() @State private var trezorManager: TrezorManager @@ -56,7 +56,6 @@ struct AppScene: View { init() { let sheetViewModel = SheetViewModel() let navigationViewModel = NavigationViewModel() - let pubkyProfile = PubkyProfileManager() let transferService = TransferService( lightningService: LightningService.shared, blocktankService: CoreService.shared.blocktank @@ -69,12 +68,10 @@ struct AppScene: View { _app = StateObject(wrappedValue: AppViewModel( sheetViewModel: sheetViewModel, - navigationViewModel: navigationViewModel, - pubkyProfile: pubkyProfile + navigationViewModel: navigationViewModel )) _sheets = StateObject(wrappedValue: sheetViewModel) _navigation = StateObject(wrappedValue: navigationViewModel) - _pubkyProfile = StateObject(wrappedValue: pubkyProfile) let feeEstimatesManager = FeeEstimatesManager() let walletVm = WalletViewModel( transferService: transferService, diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 91b9355cf..c66cf2fcf 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -1,12 +1,36 @@ import SwiftUI -struct MainNavView: View { - private enum PendingProfileSetupResumeState { - case inactive - case waiting - case ready +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 @EnvironmentObject private var app: AppViewModel @@ -33,16 +57,14 @@ struct MainNavView: View { } private var pendingProfileSetupResumeState: PendingProfileSetupResumeState { - guard pubkyProfile.isProfileSetupPending else { return .inactive } - guard isPaykitUIActive, - pubkyProfile.isAuthenticated, - sheets.activeSheetConfiguration == nil, - !sheets.isReplacingSheet, - navigation.currentRoute != .createProfile - else { - return .waiting - } - return .ready + resolvePendingProfileSetupResumeState( + isProfileSetupPending: pubkyProfile.isProfileSetupPending, + isPaykitUIActive: isPaykitUIActive, + isAuthenticated: pubkyProfile.isAuthenticated, + hasActiveSheet: sheets.activeSheetConfiguration != nil, + isReplacingSheet: sheets.isReplacingSheet, + currentRoute: navigation.currentRoute + ) } // Delay constants for clipboard processing @@ -60,11 +82,7 @@ struct MainNavView: View { } } .onChange(of: pendingProfileSetupResumeState, initial: true) { _, resumeState in - if resumeState == .inactive { - didResumePendingPubkyProfileSetup = false - } - guard resumeState == .ready, !didResumePendingPubkyProfileSetup else { return } - didResumePendingPubkyProfileSetup = true + guard resumeState.shouldResume(didResume: &didResumePendingPubkyProfileSetup) else { return } navigation.navigate(.createProfile) } .sheet( diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index fd2a86189..14764c2f3 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -119,7 +119,6 @@ class AppViewModel: ObservableObject { private let coreService: CoreService private let sheetViewModel: SheetViewModel private let navigationViewModel: NavigationViewModel - private let pubkyProfile: PubkyProfileManager private var scannedDataHandlingId: UUID? private var manualEntryValidationSequence: UInt64 = 0 @@ -131,14 +130,12 @@ class AppViewModel: ObservableObject { lightningService: LightningService = .shared, coreService: CoreService = .shared, sheetViewModel: SheetViewModel, - navigationViewModel: NavigationViewModel, - pubkyProfile: PubkyProfileManager + navigationViewModel: NavigationViewModel ) { self.lightningService = lightningService self.coreService = coreService self.sheetViewModel = sheetViewModel self.navigationViewModel = navigationViewModel - self.pubkyProfile = pubkyProfile setupManualEntryValidationDebounce() @@ -250,8 +247,7 @@ class AppViewModel: ObservableObject { convenience init() { self.init( sheetViewModel: SheetViewModel(), - navigationViewModel: NavigationViewModel(), - pubkyProfile: PubkyProfileManager() + navigationViewModel: NavigationViewModel() ) } 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/ShopPaymentRequestTests.swift b/BitkitTests/ShopPaymentRequestTests.swift index f1f705ec2..ec457ace8 100644 --- a/BitkitTests/ShopPaymentRequestTests.swift +++ b/BitkitTests/ShopPaymentRequestTests.swift @@ -66,8 +66,7 @@ final class ShopPaymentRequestTests: XCTestCase { let sheets = SheetViewModel() let app = AppViewModel( sheetViewModel: sheets, - navigationViewModel: NavigationViewModel(), - pubkyProfile: PubkyProfileManager() + navigationViewModel: NavigationViewModel() ) try await app.handleScannedData(url) From ae4b58465f323650deaff668f44bfc2ec0ed5ad3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 02:17:43 +0200 Subject: [PATCH 26/40] feat: open Pubky auth links --- Bitkit/Info.plist | 1 + Bitkit/ViewModels/AppViewModel.swift | 6 +++++- BitkitTests/PubkyAuthURLSchemeTests.swift | 11 +++++++++++ changelog.d/next/715.added.md | 1 + journeys/README.md | 5 +++-- journeys/pubky-auth/README.md | 12 ++++++++++++ journeys/pubky-auth/open-watch-only-link.xml | 13 +++++++++++++ 7 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 BitkitTests/PubkyAuthURLSchemeTests.swift create mode 100644 changelog.d/next/715.added.md create mode 100644 journeys/pubky-auth/README.md create mode 100644 journeys/pubky-auth/open-watch-only-link.xml diff --git a/Bitkit/Info.plist b/Bitkit/Info.plist index 020553009..1a3532cdc 100644 --- a/Bitkit/Info.plist +++ b/Bitkit/Info.plist @@ -8,6 +8,7 @@ CFBundleURLSchemes bitkit + pubkyauth bitcoin BITCOIN lightning diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index f9470eba6..d7a7d65f2 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -807,7 +807,11 @@ extension AppViewModel { 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")) + toast( + type: .error, + title: t("pubky_auth__invalid_request"), + accessibilityIdentifier: "PubkyAuthInvalidRequestToast" + ) } } diff --git a/BitkitTests/PubkyAuthURLSchemeTests.swift b/BitkitTests/PubkyAuthURLSchemeTests.swift new file mode 100644 index 000000000..46830d8b3 --- /dev/null +++ b/BitkitTests/PubkyAuthURLSchemeTests.swift @@ -0,0 +1,11 @@ +@testable import Bitkit +import XCTest + +final class PubkyAuthURLSchemeTests: XCTestCase { + func testAppRegistersPubkyAuthAsInboundURLScheme() throws { + let urlTypes = try XCTUnwrap(Bundle.main.object(forInfoDictionaryKey: "CFBundleURLTypes") as? [[String: Any]]) + let schemes = urlTypes.flatMap { $0["CFBundleURLSchemes"] as? [String] ?? [] } + + XCTAssertTrue(schemes.contains("pubkyauth")) + } +} diff --git a/changelog.d/next/715.added.md b/changelog.d/next/715.added.md new file mode 100644 index 000000000..1a656a8d9 --- /dev/null +++ b/changelog.d/next/715.added.md @@ -0,0 +1 @@ +Bitkit now opens Pubky marketplace setup links directly into explicit watch-only account consent. diff --git a/journeys/README.md b/journeys/README.md index 905af7d60..68630c7c8 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -135,13 +135,14 @@ Everything else — `N0`–`N9`, `N000`, `NDecimal`, `NRemove`, `SpendingAmount* | [notification-permission](notification-permission) | 4 | Background-setup toggles | | [cjit-notifications](cjit-notifications) | 3 | Adapted — iOS notification copy differs from Android | | [hardware-wallet](hardware-wallet) | 15 | Trezor over Bridge; see `Docs/AI_DEVICE_TESTS.md` | +| [pubky-auth](pubky-auth) | 1 | OS handoff into watch-only consent; local Pubky identity required | ## Not ported **`deeplinks` (2 journeys).** The Android journeys exercise `bitkit://screen/...` routing with a dev-mode gate and a cold-start replay. iOS registers the `bitkit` URL scheme (`Bitkit/Info.plist`) -but `onOpenURL` in `Bitkit/MainNavView.swift` only handles web URLs, Pubky auth callbacks and -payment URIs — there is no screen or sheet deeplink router, and no dev-mode gate to test. These +but `onOpenURL` in `Bitkit/MainNavView.swift` only handles web URLs, Pubky auth requests and callbacks, +and payment URIs — there is no screen or sheet deeplink router, and no dev-mode gate to test. These journeys are blocked on the feature existing, not on the harness. ## Porting from Android diff --git a/journeys/pubky-auth/README.md b/journeys/pubky-auth/README.md new file mode 100644 index 000000000..2dbd8b4db --- /dev/null +++ b/journeys/pubky-auth/README.md @@ -0,0 +1,12 @@ +# Pubky auth + +This suite covers the OS handoff into Bitkit for `pubkyauth` setup links. It stops at explicit watch-only consent and never authorizes or exports account material. + +## Preconditions + +- Build and run Bitkit with `E2E_BUILD`. +- Complete wallet onboarding. +- Enable Paykit UI in developer settings. +- Create a Pubky profile in Bitkit so the wallet has a local identity secret. + +The journey uses a syntactically valid dummy request and does not contact its relay unless the authorization flow is completed. diff --git a/journeys/pubky-auth/open-watch-only-link.xml b/journeys/pubky-auth/open-watch-only-link.xml new file mode 100644 index 000000000..2ecffe233 --- /dev/null +++ b/journeys/pubky-auth/open-watch-only-link.xml @@ -0,0 +1,13 @@ + + Precondition: an onboarded E2E Bitkit build with Paykit UI enabled and a Bitkit-generated Pubky identity. This journey opens a local-only dummy setup request and cancels before account material is exported. + + Run `xcrun simctl openurl <UDID> "pubkyauth://signin?caps=/pub/paykit/v0/bitkit/server/:rw,/pub/paykit/v0/private/bitkit/server/:rw&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&x-bitkit-claim=watch-only-account-v1"` + If the simulator asks to open the link in Bitkit, tap Open + Verify the watch-only consent screen (id "PubkyAuthWatchOnlyConsent") is visible + Tap Cancel (id "PubkyAuthWatchOnlyCancel") + Verify the watch-only consent screen (id "PubkyAuthWatchOnlyConsent") is no longer visible + Run `xcrun simctl openurl <UDID> "pubkyauth://signin?caps=/pub/paykit/v0/bitkit/server/:rw,/pub/paykit/v0/private/bitkit/server/:rw&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&x-bitkit-claim=unsupported-v1"` + Verify the invalid request toast (id "PubkyAuthInvalidRequestToast") is visible + Verify the watch-only consent screen (id "PubkyAuthWatchOnlyConsent") is not visible + + From 9dac17b6bf92a1f25808dfc056a4280f5bbb4f5f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 02:23:53 +0200 Subject: [PATCH 27/40] fix: preserve Pubky Ring handoff --- Bitkit/Info.plist | 2 +- Bitkit/Managers/PubkyProfileManager.swift | 15 +++++++-- BitkitTests/PubkyAuthURLSchemeTests.swift | 6 ++++ BitkitTests/PubkyProfileManagerTests.swift | 38 ++++++++++++++++++++++ 4 files changed, 58 insertions(+), 3 deletions(-) diff --git a/Bitkit/Info.plist b/Bitkit/Info.plist index 1a3532cdc..f01443798 100644 --- a/Bitkit/Info.plist +++ b/Bitkit/Info.plist @@ -38,7 +38,7 @@ $(TREZOR_ELECTRUM_URL) LSApplicationQueriesSchemes - pubkyauth + pubkyring NSAppTransportSecurity diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index b3da17c3d..05efad323 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -95,6 +95,17 @@ enum PubkyRingAuthURLBuilder { 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 @@ -389,7 +400,7 @@ class PubkyProfileManager: ObservableObject { } static func isRingAvailable() -> Bool { - guard let url = URL(string: "pubkyauth://check") else { + guard let url = URL(string: "pubkyring://check") else { return false } @@ -477,7 +488,7 @@ class PubkyProfileManager: ObservableObject { let callbackAuthUrl = PubkyRingAuthURLBuilder.addingCallbacks(to: authUrl, nonce: attemptID) ?? authUrl - guard let url = URL(string: callbackAuthUrl) else { + guard let url = PubkyRingAuthURLBuilder.ringHandoffURL(from: callbackAuthUrl) else { await cancelPendingAuthSetup() activeAuthAttemptID = nil restoreAuthStateAfterAuthFlow() diff --git a/BitkitTests/PubkyAuthURLSchemeTests.swift b/BitkitTests/PubkyAuthURLSchemeTests.swift index 46830d8b3..1c8f5ea43 100644 --- a/BitkitTests/PubkyAuthURLSchemeTests.swift +++ b/BitkitTests/PubkyAuthURLSchemeTests.swift @@ -8,4 +8,10 @@ final class PubkyAuthURLSchemeTests: XCTestCase { XCTAssertTrue(schemes.contains("pubkyauth")) } + + func testAppQueriesPubkyRingSpecificOutboundURLScheme() throws { + let schemes = try XCTUnwrap(Bundle.main.object(forInfoDictionaryKey: "LSApplicationQueriesSchemes") as? [String]) + + XCTAssertTrue(schemes.contains("pubkyring")) + } } diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index 43be620ac..28a32b1a8 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -46,6 +46,44 @@ final class PubkyProfileManagerTests: XCTestCase { 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"))), From d24cb4039ccf03107cc0d17264a30296b87915d7 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 02:25:18 +0200 Subject: [PATCH 28/40] chore: rename changelog fragment --- changelog.d/next/{715.added.md => 722.added.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{715.added.md => 722.added.md} (100%) diff --git a/changelog.d/next/715.added.md b/changelog.d/next/722.added.md similarity index 100% rename from changelog.d/next/715.added.md rename to changelog.d/next/722.added.md From 6ce8c657d31cf23b122b86f6c09b18dea8ace3ab Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 02:57:26 +0200 Subject: [PATCH 29/40] fix: retain deep links through startup --- Bitkit/AppScene.swift | 1 + Bitkit/MainNavView.swift | 134 ++++++++++--------- Bitkit/ViewModels/AppViewModel.swift | 11 ++ BitkitTests/PubkyAuthURLSchemeTests.swift | 13 ++ journeys/pubky-auth/README.md | 1 + journeys/pubky-auth/open-watch-only-link.xml | 3 +- 6 files changed, 99 insertions(+), 64 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index ab8f05a7f..5a37a5d9f 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -146,6 +146,7 @@ struct AppScene: View { .onChange(of: wallet.nodeLifecycleState) { _, newValue in handleNodeLifecycleChange(newValue) } .onChange(of: scenePhase, initial: true) { _, newValue in handleScenePhaseChange(newValue) } .onChange(of: network.isConnected) { _, isConnected in handleNetworkChange(isConnected) } + .onOpenURL { url in app.retainDeepLink(url) } // Bridge Trezor device state into the watch-only manager without coupling the two: // TrezorManager bumps devicesRevision on any device/connection change. .onChange(of: trezorManager.devicesRevision) { _, _ in pushHardwareDevices() } diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index a1b201357..d136b1a0c 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -317,69 +317,12 @@ struct MainNavView: View { notificationManager.unregister() } } - .onOpenURL { url in - Task { - Logger.info("Received deeplink: \(sanitizedDeeplinkDescription(url))") - - // Web URLs from widgets (e.g. news article tap) bypass payment handling - if let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" { - await UIApplication.shared.open(url) - return - } - - if let callback = PubkyRingAuthCallback.parse(url: url) { - guard isPaykitUIActive else { - app.toast( - type: .error, - title: t("profile__auth_error_title"), - description: t("other__qr_error_text") - ) - 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 - } - - return - } - - do { - try await app.handleScannedData( - url.absoluteString, - alternativeOnchainBalanceSats: hwWalletManager.maximumFundingBalanceSats - ) - if shouldOpenPaymentSheet(for: url.absoluteString) { - PaymentNavigationHelper.openPaymentSheet( - app: app, - currency: currency, - settings: settings, - sheetViewModel: sheets - ) - } - } catch { - Logger.error(error, context: "Failed to handle deeplink") - app.toast( - type: .error, - title: t("other__qr_error_header"), - description: t("other__qr_error_text") - ) - } - } + .task { + await handlePendingDeepLink() + } + .onChange(of: app.pendingDeepLinkURL) { _, url in + guard url != nil else { return } + Task { await handlePendingDeepLink() } } .alert( t("other__clipboard_redirect_title"), @@ -698,6 +641,71 @@ struct MainNavView: View { !SamRockSetupRequest.isProtocolURL(uri) && !PubkyAuthRequest.isProtocolURL(uri) } + private func handlePendingDeepLink() async { + guard let url = app.takePendingDeepLink() else { return } + + Logger.info("Received deeplink: \(sanitizedDeeplinkDescription(url))") + + // Web URLs from widgets (e.g. news article tap) bypass payment handling + if let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" { + await UIApplication.shared.open(url) + return + } + + if let callback = PubkyRingAuthCallback.parse(url: url) { + guard isPaykitUIActive else { + app.toast( + type: .error, + title: t("profile__auth_error_title"), + description: t("other__qr_error_text") + ) + 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 + } + + return + } + + do { + try await app.handleScannedData( + url.absoluteString, + alternativeOnchainBalanceSats: hwWalletManager.maximumFundingBalanceSats + ) + if shouldOpenPaymentSheet(for: url.absoluteString) { + PaymentNavigationHelper.openPaymentSheet( + app: app, + currency: currency, + settings: settings, + sheetViewModel: sheets + ) + } + } catch { + Logger.error(error, context: "Failed to handle deeplink") + app.toast( + type: .error, + title: t("other__qr_error_header"), + description: t("other__qr_error_text") + ) + } + } + private func sanitizedDeeplinkDescription(_ url: URL) -> String { if let description = SamRockSetupRequest.sanitizedDescription(url.absoluteString) { return description diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index d7a7d65f2..306afa417 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -67,6 +67,8 @@ class AppViewModel: ObservableObject { @Published var lnurlPayData: LnurlPayData? @Published var lnurlWithdrawData: LnurlWithdrawData? + @Published private(set) var pendingDeepLinkURL: URL? + // Onboarding @AppStorage("hasDismissedWidgetsOnboardingHint") var hasDismissedWidgetsOnboardingHint: Bool = false @AppStorage("hasSeenContactsIntro") var hasSeenContactsIntro: Bool = false @@ -115,6 +117,15 @@ class AppViewModel: ObservableObject { appStatusInit = true } + func retainDeepLink(_ url: URL) { + pendingDeepLinkURL = url + } + + func takePendingDeepLink() -> URL? { + defer { pendingDeepLinkURL = nil } + return pendingDeepLinkURL + } + private let lightningService: LightningService private let coreService: CoreService private let sheetViewModel: SheetViewModel diff --git a/BitkitTests/PubkyAuthURLSchemeTests.swift b/BitkitTests/PubkyAuthURLSchemeTests.swift index 1c8f5ea43..02f73aa03 100644 --- a/BitkitTests/PubkyAuthURLSchemeTests.swift +++ b/BitkitTests/PubkyAuthURLSchemeTests.swift @@ -14,4 +14,17 @@ final class PubkyAuthURLSchemeTests: XCTestCase { XCTAssertTrue(schemes.contains("pubkyring")) } + + @MainActor + func testAppRetainsPubkyAuthURLUntilMainNavigationConsumesIt() throws { + let app = AppViewModel() + let url = try XCTUnwrap(URL(string: "pubkyauth://signin?x-bitkit-claim=watch-only-account-v1")) + + app.retainDeepLink(url) + + XCTAssertEqual(app.pendingDeepLinkURL, url) + XCTAssertEqual(app.takePendingDeepLink(), url) + XCTAssertNil(app.pendingDeepLinkURL) + XCTAssertNil(app.takePendingDeepLink()) + } } diff --git a/journeys/pubky-auth/README.md b/journeys/pubky-auth/README.md index 2dbd8b4db..e8fe2a4ad 100644 --- a/journeys/pubky-auth/README.md +++ b/journeys/pubky-auth/README.md @@ -1,6 +1,7 @@ # Pubky auth This suite covers the OS handoff into Bitkit for `pubkyauth` setup links. It stops at explicit watch-only consent and never authorizes or exports account material. +Bitkit retains links delivered during startup, restoration, or PIN entry and presents consent only after the main wallet UI is available. ## Preconditions diff --git a/journeys/pubky-auth/open-watch-only-link.xml b/journeys/pubky-auth/open-watch-only-link.xml index 2ecffe233..de8706f63 100644 --- a/journeys/pubky-auth/open-watch-only-link.xml +++ b/journeys/pubky-auth/open-watch-only-link.xml @@ -1,6 +1,7 @@ - Precondition: an onboarded E2E Bitkit build with Paykit UI enabled and a Bitkit-generated Pubky identity. This journey opens a local-only dummy setup request and cancels before account material is exported. + Precondition: an onboarded E2E Bitkit build with Paykit UI enabled and a Bitkit-generated Pubky identity. This journey launches the terminated app with a local-only dummy setup request and cancels before account material is exported. + Run `xcrun simctl terminate <UDID> to.bitkit` Run `xcrun simctl openurl <UDID> "pubkyauth://signin?caps=/pub/paykit/v0/bitkit/server/:rw,/pub/paykit/v0/private/bitkit/server/:rw&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&x-bitkit-claim=watch-only-account-v1"` If the simulator asks to open the link in Bitkit, tap Open Verify the watch-only consent screen (id "PubkyAuthWatchOnlyConsent") is visible From 816ad3867ff62255e9c34f7da265f732613ee160 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 03:23:55 +0200 Subject: [PATCH 30/40] fix: target Bitkit Pubky auth links uniquely --- Bitkit/AppScene.swift | 2 +- Bitkit/Info.plist | 1 - Bitkit/MainNavView.swift | 17 ++++- Bitkit/Models/PubkyAuthRequest.swift | 35 +++++++++-- Bitkit/ViewModels/AppViewModel.swift | 9 +-- BitkitTests/PubkyAuthRequestTests.swift | 45 ++++++++++++++ BitkitTests/PubkyAuthURLSchemeTests.swift | 65 +++++++++++++++++--- journeys/README.md | 4 +- journeys/pubky-auth/README.md | 3 +- journeys/pubky-auth/open-watch-only-link.xml | 4 +- 10 files changed, 159 insertions(+), 26 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 5a37a5d9f..40c6737a1 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -403,7 +403,7 @@ struct AppScene: View { } ) } else { - MainNavView() + MainNavView(canHandleDeepLinks: wallet.nodeLifecycleState == .running) } } } diff --git a/Bitkit/Info.plist b/Bitkit/Info.plist index f01443798..5e1652411 100644 --- a/Bitkit/Info.plist +++ b/Bitkit/Info.plist @@ -8,7 +8,6 @@ CFBundleURLSchemes bitkit - pubkyauth bitcoin BITCOIN lightning diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index d136b1a0c..21f989eb1 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -1,6 +1,8 @@ import SwiftUI struct MainNavView: View { + private let canHandleDeepLinks: Bool + @AppStorage(PaykitFeatureFlags.uiEnabledKey) private var isPaykitUIEnabled = false @EnvironmentObject private var app: AppViewModel @@ -21,6 +23,10 @@ struct MainNavView: View { @State private var showClipboardAlert = false @State private var clipboardUri: String? + init(canHandleDeepLinks: Bool = true) { + self.canHandleDeepLinks = canHandleDeepLinks + } + private var isPaykitUIActive: Bool { PaykitFeatureFlags.isUIAvailable && isPaykitUIEnabled } @@ -317,11 +323,12 @@ struct MainNavView: View { notificationManager.unregister() } } - .task { + .task(id: canHandleDeepLinks) { + guard canHandleDeepLinks else { return } await handlePendingDeepLink() } .onChange(of: app.pendingDeepLinkURL) { _, url in - guard url != nil else { return } + guard canHandleDeepLinks, url != nil else { return } Task { await handlePendingDeepLink() } } .alert( @@ -642,8 +649,12 @@ struct MainNavView: View { } private func handlePendingDeepLink() async { - guard let url = app.takePendingDeepLink() else { return } + await app.routePendingDeepLinkIfReady(canHandleDeepLinks) { url in + await handleDeepLink(url) + } + } + private func handleDeepLink(_ url: URL) async { Logger.info("Received deeplink: \(sanitizedDeeplinkDescription(url))") // Web URLs from widgets (e.g. news article tap) bypass payment handling diff --git a/Bitkit/Models/PubkyAuthRequest.swift b/Bitkit/Models/PubkyAuthRequest.swift index 8b06f15c1..4f4e1116b 100644 --- a/Bitkit/Models/PubkyAuthRequest.swift +++ b/Bitkit/Models/PubkyAuthRequest.swift @@ -51,6 +51,9 @@ struct PubkyAuthPermission { // MARK: - PubkyAuth Request (parsed from pubkyauth:// URL) struct PubkyAuthRequest { + private static let bitkitSetupHost = "pubky-auth" + private static let bitkitSetupPath = "/setup" + let rawUrl: String let kind: Paykit.PubkyAuthRequestKind let relay: String @@ -60,20 +63,44 @@ struct PubkyAuthRequest { let bitkitClaim: PubkyAuthClaim? static func isProtocolURL(_ value: String) -> Bool { - URLComponents(string: value.trimmingCharacters(in: .whitespacesAndNewlines))?.scheme?.lowercased() == "pubkyauth" + URLComponents(string: normalizedProtocolURL(value).trimmingCharacters(in: .whitespacesAndNewlines))?.scheme?.lowercased() == "pubkyauth" + } + + /// Normalizes Bitkit's unique iOS handoff because the OS cannot deterministically route a custom scheme shared with Pubky Ring. + static func normalizedProtocolURL(_ value: String) -> String { + let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard let components = URLComponents(string: trimmedValue), + components.scheme?.lowercased() == "bitkit", + components.host?.lowercased() == bitkitSetupHost, + components.path == bitkitSetupPath, + components.user == nil, + components.password == nil, + components.port == nil + else { + return value + } + + guard let queryDelimiter = trimmedValue.firstIndex(of: "?") else { + return "pubkyauth://signin" + } + + let queryStart = trimmedValue.index(after: queryDelimiter) + let fragmentDelimiter = trimmedValue[queryStart...].firstIndex(of: "#") ?? trimmedValue.endIndex + return "pubkyauth://signin?\(trimmedValue[queryStart ..< fragmentDelimiter])" } static func parse(url: String) throws -> PubkyAuthRequest { - let details = try Paykit.parsePubkyAuthUrl(authUrl: url) + let normalizedURL = normalizedProtocolURL(url) + let details = try Paykit.parsePubkyAuthUrl(authUrl: normalizedURL) let capabilities = details.capabilities ?? "" let permissions = parseCapabilities(capabilities) var seenServiceNames = Set() let serviceNames = permissions .compactMap { extractServiceName($0.path) } .filter { seenServiceNames.insert($0).inserted } - let bitkitClaim = try parseBitkitClaim(url: url, capabilities: capabilities) + let bitkitClaim = try parseBitkitClaim(url: normalizedURL, capabilities: capabilities) return PubkyAuthRequest( - rawUrl: url, + rawUrl: normalizedURL, kind: details.kind, relay: details.relayUrl ?? "", capabilities: capabilities, diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 306afa417..9bf5603b0 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -121,9 +121,10 @@ class AppViewModel: ObservableObject { pendingDeepLinkURL = url } - func takePendingDeepLink() -> URL? { - defer { pendingDeepLinkURL = nil } - return pendingDeepLinkURL + func routePendingDeepLinkIfReady(_ isReady: Bool, handler: (URL) async -> Void) async { + guard isReady, let url = pendingDeepLinkURL else { return } + pendingDeepLinkURL = nil + await handler(url) } private let lightningService: LightningService @@ -447,7 +448,7 @@ extension AppViewModel { } } - let uri = uri.removingLightningSchemes() + let uri = PubkyAuthRequest.normalizedProtocolURL(uri.removingLightningSchemes()) let prevalidatedPaymentRequest: BitkitCore.Scanner? if scope == .paymentRequests { guard SamRockSetupRequest.parse(uri) == nil, diff --git a/BitkitTests/PubkyAuthRequestTests.swift b/BitkitTests/PubkyAuthRequestTests.swift index b1b4af240..46ed9f8bb 100644 --- a/BitkitTests/PubkyAuthRequestTests.swift +++ b/BitkitTests/PubkyAuthRequestTests.swift @@ -13,6 +13,51 @@ final class PubkyAuthRequestTests: XCTestCase { XCTAssertFalse(PubkyAuthRequest.isProtocolURL("lightning:lnbc1example")) } + func testProtocolUrlNormalizesBitkitSpecificSetupHandoff() throws { + let url = "bitkit://pubky-auth/setup?caps=\(PubkyAuthClaim.watchOnlyAccountCapabilities)" + + "&relay=\(relay)&secret=\(secret)&x-bitkit-claim=watch-only-account-v1" + + XCTAssertTrue(PubkyAuthRequest.isProtocolURL(url)) + + let request = try PubkyAuthRequest.parse(url: url) + + XCTAssertTrue(request.rawUrl.hasPrefix("pubkyauth://signin?")) + XCTAssertEqual(request.bitkitClaim, .watchOnlyAccountV1) + XCTAssertEqual(request.capabilities, PubkyAuthClaim.watchOnlyAccountCapabilities) + } + + func testProtocolUrlDoesNotTreatPubkyRingCallbackAsSetupHandoff() { + let url = "bitkit://pubky-auth/success?nonce=123" + + XCTAssertFalse(PubkyAuthRequest.isProtocolURL(url)) + XCTAssertEqual(PubkyAuthRequest.normalizedProtocolURL(url), url) + } + + func testProtocolUrlPreservesEncodedQueryOrderAndDropsFragment() { + let query = "caps=a%2Fb&relay=https%3A%2F%2Fx&secret=first&secret=second" + + XCTAssertEqual( + PubkyAuthRequest.normalizedProtocolURL("bitkit://pubky-auth/setup?\(query)#ignored"), + "pubkyauth://signin?\(query)" + ) + } + + func testProtocolUrlDoesNotReserializeRawQueryBytes() { + let query = "caps=&relay=https%3A%2F%2Fx&secret=first&secret=second" + + XCTAssertEqual( + PubkyAuthRequest.normalizedProtocolURL("bitkit://pubky-auth/setup?\(query)#ignored"), + "pubkyauth://signin?\(query)" + ) + } + + func testProtocolUrlRejectsBitkitSetupHandoffWithoutQuery() { + let url = "bitkit://pubky-auth/setup" + + XCTAssertTrue(PubkyAuthRequest.isProtocolURL(url)) + XCTAssertThrowsError(try PubkyAuthRequest.parse(url: url)) + } + func testParseUrlPreservesRequestedCapabilities() throws { let capabilities = "/pub/bitkit.to/:rw" let url = "pubkyauth://signin?caps=\(capabilities)&relay=https://httprelay.pubky.app/inbox/&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s" diff --git a/BitkitTests/PubkyAuthURLSchemeTests.swift b/BitkitTests/PubkyAuthURLSchemeTests.swift index 02f73aa03..f39e9dbd0 100644 --- a/BitkitTests/PubkyAuthURLSchemeTests.swift +++ b/BitkitTests/PubkyAuthURLSchemeTests.swift @@ -2,11 +2,12 @@ import XCTest final class PubkyAuthURLSchemeTests: XCTestCase { - func testAppRegistersPubkyAuthAsInboundURLScheme() throws { + func testAppUsesUniqueBitkitSchemeInsteadOfSharedPubkyAuthScheme() throws { let urlTypes = try XCTUnwrap(Bundle.main.object(forInfoDictionaryKey: "CFBundleURLTypes") as? [[String: Any]]) let schemes = urlTypes.flatMap { $0["CFBundleURLSchemes"] as? [String] ?? [] } - XCTAssertTrue(schemes.contains("pubkyauth")) + XCTAssertTrue(schemes.contains("bitkit")) + XCTAssertFalse(schemes.contains("pubkyauth")) } func testAppQueriesPubkyRingSpecificOutboundURLScheme() throws { @@ -16,15 +17,63 @@ final class PubkyAuthURLSchemeTests: XCTestCase { } @MainActor - func testAppRetainsPubkyAuthURLUntilMainNavigationConsumesIt() throws { - let app = AppViewModel() - let url = try XCTUnwrap(URL(string: "pubkyauth://signin?x-bitkit-claim=watch-only-account-v1")) + func testAppDefersGatedPubkyAuthURLAndRoutesWatchOnlyConsentExactlyOnce() async throws { + let hadPreviousPaykitUIValue = UserDefaults.standard.object(forKey: PaykitFeatureFlags.uiEnabledKey) != nil + let previousPaykitUIValue = UserDefaults.standard.bool(forKey: PaykitFeatureFlags.uiEnabledKey) + let previousSession = try? Keychain.loadString(key: .paykitSession) + let previousSecretKey = try? Keychain.loadString(key: .pubkySecretKey) + try Keychain.delete(key: .paykitSession) + try Keychain.delete(key: .pubkySecretKey) + try Keychain.saveString(key: .paykitSession, str: "test-session") + try Keychain.saveString(key: .pubkySecretKey, str: "test-secret-key") + UserDefaults.standard.set(true, forKey: PaykitFeatureFlags.uiEnabledKey) + 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) + } + if hadPreviousPaykitUIValue { + UserDefaults.standard.set(previousPaykitUIValue, forKey: PaykitFeatureFlags.uiEnabledKey) + } else { + UserDefaults.standard.removeObject(forKey: PaykitFeatureFlags.uiEnabledKey) + } + } + + let sheets = SheetViewModel() + let app = AppViewModel(sheetViewModel: sheets, navigationViewModel: NavigationViewModel()) + let url = try XCTUnwrap(URL(string: "bitkit://pubky-auth/setup?caps=\(PubkyAuthClaim.watchOnlyAccountCapabilities)" + + "&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F" + + "&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&x-bitkit-claim=watch-only-account-v1")) + var routeCount = 0 app.retainDeepLink(url) + for gate in ["startup", "restoration", "PIN"] { + await app.routePendingDeepLinkIfReady(false) { _ in + XCTFail("The \(gate) gate must retain the URL while main navigation is hidden") + } + XCTAssertEqual(app.pendingDeepLinkURL, url) + } + + await app.routePendingDeepLinkIfReady(true) { routedURL in + routeCount += 1 + do { + try await app.handleScannedData(routedURL.absoluteString) + } catch { + XCTFail("The retained URL must route through the production scanner: \(error)") + } + } + await app.routePendingDeepLinkIfReady(true) { _ in + routeCount += 1 + } - XCTAssertEqual(app.pendingDeepLinkURL, url) - XCTAssertEqual(app.takePendingDeepLink(), url) + XCTAssertEqual(routeCount, 1) XCTAssertNil(app.pendingDeepLinkURL) - XCTAssertNil(app.takePendingDeepLink()) + XCTAssertEqual(sheets.activeSheetConfiguration?.id, .pubkyAuthApproval) + let config = try XCTUnwrap(sheets.activeSheetConfiguration?.data as? PubkyAuthApprovalConfig) + XCTAssertEqual(config.request.bitkitClaim, .watchOnlyAccountV1) } } diff --git a/journeys/README.md b/journeys/README.md index 68630c7c8..f40e90d50 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -135,13 +135,13 @@ Everything else — `N0`–`N9`, `N000`, `NDecimal`, `NRemove`, `SpendingAmount* | [notification-permission](notification-permission) | 4 | Background-setup toggles | | [cjit-notifications](cjit-notifications) | 3 | Adapted — iOS notification copy differs from Android | | [hardware-wallet](hardware-wallet) | 15 | Trezor over Bridge; see `Docs/AI_DEVICE_TESTS.md` | -| [pubky-auth](pubky-auth) | 1 | OS handoff into watch-only consent; local Pubky identity required | +| [pubky-auth](pubky-auth) | 1 | Bitkit-specific OS handoff into watch-only consent; local Pubky identity required | ## Not ported **`deeplinks` (2 journeys).** The Android journeys exercise `bitkit://screen/...` routing with a dev-mode gate and a cold-start replay. iOS registers the `bitkit` URL scheme (`Bitkit/Info.plist`) -but `onOpenURL` in `Bitkit/MainNavView.swift` only handles web URLs, Pubky auth requests and callbacks, +and retains external URLs in `AppScene`, but `MainNavView` only routes web URLs, Pubky auth requests and callbacks, and payment URIs — there is no screen or sheet deeplink router, and no dev-mode gate to test. These journeys are blocked on the feature existing, not on the harness. diff --git a/journeys/pubky-auth/README.md b/journeys/pubky-auth/README.md index e8fe2a4ad..8ea0c7173 100644 --- a/journeys/pubky-auth/README.md +++ b/journeys/pubky-auth/README.md @@ -1,6 +1,7 @@ # Pubky auth -This suite covers the OS handoff into Bitkit for `pubkyauth` setup links. It stops at explicit watch-only consent and never authorizes or exports account material. +This suite covers the uniquely targetable `bitkit://pubky-auth/setup` OS handoff into Bitkit. Raw `pubkyauth` setup requests remain supported through QR scanning and clipboard paste for compatibility with the Pubky protocol. +It stops at explicit watch-only consent and never authorizes or exports account material. Bitkit retains links delivered during startup, restoration, or PIN entry and presents consent only after the main wallet UI is available. ## Preconditions diff --git a/journeys/pubky-auth/open-watch-only-link.xml b/journeys/pubky-auth/open-watch-only-link.xml index de8706f63..90ec43130 100644 --- a/journeys/pubky-auth/open-watch-only-link.xml +++ b/journeys/pubky-auth/open-watch-only-link.xml @@ -2,12 +2,12 @@ Precondition: an onboarded E2E Bitkit build with Paykit UI enabled and a Bitkit-generated Pubky identity. This journey launches the terminated app with a local-only dummy setup request and cancels before account material is exported. Run `xcrun simctl terminate <UDID> to.bitkit` - Run `xcrun simctl openurl <UDID> "pubkyauth://signin?caps=/pub/paykit/v0/bitkit/server/:rw,/pub/paykit/v0/private/bitkit/server/:rw&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&x-bitkit-claim=watch-only-account-v1"` + Run `xcrun simctl openurl <UDID> "bitkit://pubky-auth/setup?caps=/pub/paykit/v0/bitkit/server/:rw,/pub/paykit/v0/private/bitkit/server/:rw&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&x-bitkit-claim=watch-only-account-v1"` If the simulator asks to open the link in Bitkit, tap Open Verify the watch-only consent screen (id "PubkyAuthWatchOnlyConsent") is visible Tap Cancel (id "PubkyAuthWatchOnlyCancel") Verify the watch-only consent screen (id "PubkyAuthWatchOnlyConsent") is no longer visible - Run `xcrun simctl openurl <UDID> "pubkyauth://signin?caps=/pub/paykit/v0/bitkit/server/:rw,/pub/paykit/v0/private/bitkit/server/:rw&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&x-bitkit-claim=unsupported-v1"` + Run `xcrun simctl openurl <UDID> "bitkit://pubky-auth/setup?caps=/pub/paykit/v0/bitkit/server/:rw,/pub/paykit/v0/private/bitkit/server/:rw&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&x-bitkit-claim=unsupported-v1"` Verify the invalid request toast (id "PubkyAuthInvalidRequestToast") is visible Verify the watch-only consent screen (id "PubkyAuthWatchOnlyConsent") is not visible From b1bd510e25b01f1684558c78f1825872a41008eb Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 03:34:36 +0200 Subject: [PATCH 31/40] fix: reject malformed Pubky handoff fragments --- Bitkit/Models/PubkyAuthRequest.swift | 4 ++-- BitkitTests/PubkyAuthRequestTests.swift | 29 +++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/Bitkit/Models/PubkyAuthRequest.swift b/Bitkit/Models/PubkyAuthRequest.swift index 4f4e1116b..4c3cb5fb2 100644 --- a/Bitkit/Models/PubkyAuthRequest.swift +++ b/Bitkit/Models/PubkyAuthRequest.swift @@ -80,12 +80,12 @@ struct PubkyAuthRequest { return value } - guard let queryDelimiter = trimmedValue.firstIndex(of: "?") else { + let fragmentDelimiter = trimmedValue.firstIndex(of: "#") ?? trimmedValue.endIndex + guard let queryDelimiter = trimmedValue[.. Date: Wed, 2 Sep 2026 03:50:35 +0200 Subject: [PATCH 32/40] fix: preserve Pubky wrapper validation --- Bitkit/Models/PubkyAuthRequest.swift | 46 +++++++++++++++-------- Bitkit/ViewModels/AppViewModel.swift | 13 ++++--- BitkitTests/PubkyAuthRequestTests.swift | 43 ++++++++++++++++----- BitkitTests/PubkyAuthURLSchemeTests.swift | 9 +++++ 4 files changed, 79 insertions(+), 32 deletions(-) diff --git a/Bitkit/Models/PubkyAuthRequest.swift b/Bitkit/Models/PubkyAuthRequest.swift index 4c3cb5fb2..d1f3e5e6d 100644 --- a/Bitkit/Models/PubkyAuthRequest.swift +++ b/Bitkit/Models/PubkyAuthRequest.swift @@ -69,27 +69,18 @@ struct PubkyAuthRequest { /// Normalizes Bitkit's unique iOS handoff because the OS cannot deterministically route a custom scheme shared with Pubky Ring. static func normalizedProtocolURL(_ value: String) -> String { let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) - guard let components = URLComponents(string: trimmedValue), - components.scheme?.lowercased() == "bitkit", - components.host?.lowercased() == bitkitSetupHost, - components.path == bitkitSetupPath, - components.user == nil, - components.password == nil, - components.port == nil + guard isBitkitSetupHandoff(trimmedValue), + let queryDelimiter = trimmedValue.firstIndex(of: "?") else { return value } - let fragmentDelimiter = trimmedValue.firstIndex(of: "#") ?? trimmedValue.endIndex - guard let queryDelimiter = trimmedValue[.. PubkyAuthRequest { + let requiresBitkitClaim = isBitkitSetupHandoff(url.trimmingCharacters(in: .whitespacesAndNewlines)) let normalizedURL = normalizedProtocolURL(url) let details = try Paykit.parsePubkyAuthUrl(authUrl: normalizedURL) let capabilities = details.capabilities ?? "" @@ -98,7 +89,11 @@ struct PubkyAuthRequest { let serviceNames = permissions .compactMap { extractServiceName($0.path) } .filter { seenServiceNames.insert($0).inserted } - let bitkitClaim = try parseBitkitClaim(url: normalizedURL, capabilities: capabilities) + let bitkitClaim = try parseBitkitClaim( + url: normalizedURL, + capabilities: capabilities, + requiresBitkitClaim: requiresBitkitClaim + ) return PubkyAuthRequest( rawUrl: normalizedURL, kind: details.kind, @@ -110,7 +105,7 @@ struct PubkyAuthRequest { ) } - static func parseBitkitClaim(url: String, capabilities: String) throws -> PubkyAuthClaim? { + static func parseBitkitClaim(url: String, capabilities: String, requiresBitkitClaim: Bool = false) throws -> PubkyAuthClaim? { guard let components = URLComponents(string: url) else { throw PubkyAuthRequestError.invalidUrl } @@ -123,7 +118,7 @@ struct PubkyAuthRequest { throw PubkyAuthRequestError.duplicateBitkitClaim } guard let claimValue = claimValues.first else { - if PubkyAuthClaim.matchesWatchOnlyAccountCapabilities(capabilities) { + if requiresBitkitClaim || PubkyAuthClaim.matchesWatchOnlyAccountCapabilities(capabilities) { throw PubkyAuthRequestError.missingBitkitClaim } return nil @@ -138,6 +133,25 @@ struct PubkyAuthRequest { return claim } + private static func isBitkitSetupHandoff(_ value: String) -> Bool { + guard let components = URLComponents(string: value), + components.scheme?.lowercased() == "bitkit", + components.host?.lowercased() == bitkitSetupHost, + components.path == bitkitSetupPath, + components.user == nil, + components.password == nil, + components.port == nil, + components.fragment == nil, + let query = components.percentEncodedQuery, + !query.isEmpty, + !query.hasPrefix("?") + else { + return false + } + + return true + } + static func parseCapabilities(_ caps: String) -> [PubkyAuthPermission] { caps .split(separator: ",") diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 9bf5603b0..5671b8348 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -448,7 +448,8 @@ extension AppViewModel { } } - let uri = PubkyAuthRequest.normalizedProtocolURL(uri.removingLightningSchemes()) + let sourceURI = uri.removingLightningSchemes() + let uri = PubkyAuthRequest.normalizedProtocolURL(sourceURI) let prevalidatedPaymentRequest: BitkitCore.Scanner? if scope == .paymentRequests { guard SamRockSetupRequest.parse(uri) == nil, @@ -661,7 +662,7 @@ extension AppViewModel { } handleNodeUri(url) - case let .pubkyAuth(data: authUrl): + case .pubkyAuth: guard PaykitFeatureFlags.isUIEnabled else { toast( type: .error, @@ -671,7 +672,7 @@ extension AppViewModel { ) return } - handlePubkyAuthApproval(authUrl) + handlePubkyAuthApproval(sourceURI) case let .gift(code, amount): sheetViewModel.showSheet(.gift, data: GiftConfig(code: code, amount: Int(amount))) default: @@ -798,7 +799,7 @@ extension AppViewModel { sheetViewModel.showSheet(.lnurlAuth, data: LnurlAuthConfig(lnurl: lnurl, authData: data)) } - private func handlePubkyAuthApproval(_ authUrl: String) { + private func handlePubkyAuthApproval(_ sourceURL: String) { // State 1: No Pubky identity at all guard (try? Keychain.loadString(key: .paykitSession))?.isEmpty == false else { toast(type: .warning, title: t("pubky_auth__no_identity"), description: t("pubky_auth__no_identity_desc")) @@ -815,8 +816,8 @@ extension AppViewModel { // State 3: Bitkit-generated identity — can approve do { - let request = try PubkyAuthRequest.parse(url: authUrl) - sheetViewModel.showSheet(.pubkyAuthApproval, data: PubkyAuthApprovalConfig(authUrl: authUrl, request: request)) + let request = try PubkyAuthRequest.parse(url: sourceURL) + sheetViewModel.showSheet(.pubkyAuthApproval, data: PubkyAuthApprovalConfig(authUrl: request.rawUrl, request: request)) } catch { Logger.error("Failed to parse pubky auth URL: \(error)", context: "AppViewModel") toast( diff --git a/BitkitTests/PubkyAuthRequestTests.swift b/BitkitTests/PubkyAuthRequestTests.swift index 052716ac6..5449dcb5a 100644 --- a/BitkitTests/PubkyAuthRequestTests.swift +++ b/BitkitTests/PubkyAuthRequestTests.swift @@ -35,6 +35,14 @@ final class PubkyAuthRequestTests: XCTestCase { } } + func testProtocolUrlRejectsGenericBitkitSetupHandoffWithoutClaimMarker() { + let url = "bitkit://pubky-auth/setup?caps=/pub/locks.app/:rw&relay=\(relay)&secret=\(secret)" + + XCTAssertThrowsError(try PubkyAuthRequest.parse(url: url)) { + XCTAssertEqual($0 as? PubkyAuthRequestError, .missingBitkitClaim) + } + } + func testProtocolUrlDoesNotTreatPubkyRingCallbackAsSetupHandoff() { let url = "bitkit://pubky-auth/success?nonce=123" @@ -55,20 +63,20 @@ final class PubkyAuthRequestTests: XCTestCase { } } - func testProtocolUrlPreservesEncodedQueryOrderAndDropsFragment() { + func testProtocolUrlRejectsFragment() { let query = "caps=a%2Fb&relay=https%3A%2F%2Fx&secret=first&secret=second" + let url = "bitkit://pubky-auth/setup?\(query)#ignored" - XCTAssertEqual( - PubkyAuthRequest.normalizedProtocolURL("bitkit://pubky-auth/setup?\(query)#ignored"), - "pubkyauth://signin?\(query)" - ) + XCTAssertFalse(PubkyAuthRequest.isProtocolURL(url)) + XCTAssertEqual(PubkyAuthRequest.normalizedProtocolURL(url), url) + XCTAssertThrowsError(try PubkyAuthRequest.parse(url: url)) } - func testProtocolUrlDoesNotReserializeRawQueryBytes() { - let query = "caps=&relay=https%3A%2F%2Fx&secret=first&secret=second" + func testProtocolUrlDoesNotReserializeRawOrEncodedQueryBytes() { + let query = "caps=%23encoded&relay=https%3A%2F%2Fx&secret=first&secret=second" XCTAssertEqual( - PubkyAuthRequest.normalizedProtocolURL("bitkit://pubky-auth/setup?\(query)#ignored"), + PubkyAuthRequest.normalizedProtocolURL("bitkit://pubky-auth/setup?\(query)"), "pubkyauth://signin?\(query)" ) } @@ -76,14 +84,29 @@ final class PubkyAuthRequestTests: XCTestCase { func testProtocolUrlRejectsBitkitSetupHandoffWithoutQuery() { let url = "bitkit://pubky-auth/setup" - XCTAssertTrue(PubkyAuthRequest.isProtocolURL(url)) + XCTAssertFalse(PubkyAuthRequest.isProtocolURL(url)) + XCTAssertEqual(PubkyAuthRequest.normalizedProtocolURL(url), url) XCTAssertThrowsError(try PubkyAuthRequest.parse(url: url)) } + func testProtocolUrlRejectsEmptyOrDuplicateQueryDelimiter() { + let urls = [ + "bitkit://pubky-auth/setup?", + "bitkit://pubky-auth/setup??secret=first", + ] + + for url in urls { + XCTAssertFalse(PubkyAuthRequest.isProtocolURL(url)) + XCTAssertEqual(PubkyAuthRequest.normalizedProtocolURL(url), url) + XCTAssertThrowsError(try PubkyAuthRequest.parse(url: url)) + } + } + func testProtocolUrlDoesNotTreatFragmentQuestionMarkAsQuery() { let url = "bitkit://pubky-auth/setup#ignored?caps=" - XCTAssertEqual(PubkyAuthRequest.normalizedProtocolURL(url), "pubkyauth://signin") + XCTAssertFalse(PubkyAuthRequest.isProtocolURL(url)) + XCTAssertEqual(PubkyAuthRequest.normalizedProtocolURL(url), url) XCTAssertThrowsError(try PubkyAuthRequest.parse(url: url)) } diff --git a/BitkitTests/PubkyAuthURLSchemeTests.swift b/BitkitTests/PubkyAuthURLSchemeTests.swift index f39e9dbd0..86942cf94 100644 --- a/BitkitTests/PubkyAuthURLSchemeTests.swift +++ b/BitkitTests/PubkyAuthURLSchemeTests.swift @@ -75,5 +75,14 @@ final class PubkyAuthURLSchemeTests: XCTestCase { XCTAssertEqual(sheets.activeSheetConfiguration?.id, .pubkyAuthApproval) let config = try XCTUnwrap(sheets.activeSheetConfiguration?.data as? PubkyAuthApprovalConfig) XCTAssertEqual(config.request.bitkitClaim, .watchOnlyAccountV1) + XCTAssertTrue(config.authUrl.hasPrefix("pubkyauth://signin?")) + + sheets.hideSheet() + let markerlessURL = "bitkit://pubky-auth/setup?caps=/pub/locks.app/:rw" + + "&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F" + + "&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s" + try await app.handleScannedData(markerlessURL) + + XCTAssertNil(sheets.activeSheetConfiguration) } } From 2687f89877631054b0ed9ca7b106c491b0e382c1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 23:43:18 +0200 Subject: [PATCH 33/40] fix: ungated Pubky links; reject dupes --- Bitkit/AppScene.swift | 2 +- Bitkit/MainNavView.swift | 7 ++- Bitkit/Models/PubkyAuthRequest.swift | 13 ++++++ Bitkit/ViewModels/AppViewModel.swift | 15 ++++++- BitkitTests/PubkyAuthRequestTests.swift | 16 +++++++ BitkitTests/PubkyAuthURLSchemeTests.swift | 52 +++++++++++++++++++++++ 6 files changed, 101 insertions(+), 4 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 40c6737a1..5a37a5d9f 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -403,7 +403,7 @@ struct AppScene: View { } ) } else { - MainNavView(canHandleDeepLinks: wallet.nodeLifecycleState == .running) + MainNavView() } } } diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 21f989eb1..da674bfa3 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -323,7 +323,7 @@ struct MainNavView: View { notificationManager.unregister() } } - .task(id: canHandleDeepLinks) { + .task(id: [canHandleDeepLinks, wallet.nodeLifecycleState == .running]) { guard canHandleDeepLinks else { return } await handlePendingDeepLink() } @@ -649,7 +649,10 @@ struct MainNavView: View { } private func handlePendingDeepLink() async { - await app.routePendingDeepLinkIfReady(canHandleDeepLinks) { url in + await app.routePendingDeepLinkIfReady( + canHandleDeepLinks, + nodeIsRunning: wallet.nodeLifecycleState == .running + ) { url in await handleDeepLink(url) } } diff --git a/Bitkit/Models/PubkyAuthRequest.swift b/Bitkit/Models/PubkyAuthRequest.swift index d1f3e5e6d..7298b32a4 100644 --- a/Bitkit/Models/PubkyAuthRequest.swift +++ b/Bitkit/Models/PubkyAuthRequest.swift @@ -26,6 +26,8 @@ enum PubkyAuthRequestError: Error, Equatable { case invalidUrl case missingBitkitClaim case duplicateBitkitClaim + case duplicateRelay + case duplicateSecret case unsupportedBitkitClaim(String) case invalidBitkitClaimCapabilities } @@ -82,6 +84,7 @@ struct PubkyAuthRequest { static func parse(url: String) throws -> PubkyAuthRequest { let requiresBitkitClaim = isBitkitSetupHandoff(url.trimmingCharacters(in: .whitespacesAndNewlines)) let normalizedURL = normalizedProtocolURL(url) + try rejectDuplicateRelayAndSecret(in: normalizedURL) let details = try Paykit.parsePubkyAuthUrl(authUrl: normalizedURL) let capabilities = details.capabilities ?? "" let permissions = parseCapabilities(capabilities) @@ -105,6 +108,16 @@ struct PubkyAuthRequest { ) } + private static func rejectDuplicateRelayAndSecret(in url: String) throws { + guard let items = URLComponents(string: url)?.queryItems else { return } + if items.filter({ $0.name == "relay" }).count > 1 { + throw PubkyAuthRequestError.duplicateRelay + } + if items.filter({ $0.name == "secret" }).count > 1 { + throw PubkyAuthRequestError.duplicateSecret + } + } + static func parseBitkitClaim(url: String, capabilities: String, requiresBitkitClaim: Bool = false) throws -> PubkyAuthClaim? { guard let components = URLComponents(string: url) else { throw PubkyAuthRequestError.invalidUrl diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 5671b8348..5bd7c549b 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -121,12 +121,25 @@ class AppViewModel: ObservableObject { pendingDeepLinkURL = url } - func routePendingDeepLinkIfReady(_ isReady: Bool, handler: (URL) async -> Void) async { + func routePendingDeepLinkIfReady(_ isReady: Bool, nodeIsRunning: Bool = false, handler: (URL) async -> Void) async { guard isReady, let url = pendingDeepLinkURL else { return } + if Self.requiresLightningNode(url), !nodeIsRunning { + return + } pendingDeepLinkURL = nil await handler(url) } + private static func requiresLightningNode(_ url: URL) -> Bool { + if let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" { + return false + } + if PubkyRingAuthCallback.parse(url: url) != nil { + return false + } + return !PubkyAuthRequest.isProtocolURL(url.absoluteString) + } + private let lightningService: LightningService private let coreService: CoreService private let sheetViewModel: SheetViewModel diff --git a/BitkitTests/PubkyAuthRequestTests.swift b/BitkitTests/PubkyAuthRequestTests.swift index 5449dcb5a..aba6076fe 100644 --- a/BitkitTests/PubkyAuthRequestTests.swift +++ b/BitkitTests/PubkyAuthRequestTests.swift @@ -194,6 +194,22 @@ final class PubkyAuthRequestTests: XCTestCase { } } + func testParseUrlRejectsDuplicateRelay() { + let url = "pubkyauth://signin?caps=/pub/example/:rw&relay=https://a&relay=https://b&secret=\(secret)" + + XCTAssertThrowsError(try PubkyAuthRequest.parse(url: url)) { + XCTAssertEqual($0 as? PubkyAuthRequestError, .duplicateRelay) + } + } + + func testParseUrlRejectsDuplicateSecret() { + let url = "pubkyauth://signin?caps=/pub/example/:rw&relay=https://a&secret=first&secret=second" + + XCTAssertThrowsError(try PubkyAuthRequest.parse(url: url)) { + XCTAssertEqual($0 as? PubkyAuthRequestError, .duplicateSecret) + } + } + func testParseUrlRejectsUnknownBitkitClaim() { let url = authUrl(capabilities: PubkyAuthClaim.watchOnlyAccountCapabilities, claimValues: ["unknown-v1"]) diff --git a/BitkitTests/PubkyAuthURLSchemeTests.swift b/BitkitTests/PubkyAuthURLSchemeTests.swift index 86942cf94..575784c6b 100644 --- a/BitkitTests/PubkyAuthURLSchemeTests.swift +++ b/BitkitTests/PubkyAuthURLSchemeTests.swift @@ -84,5 +84,57 @@ final class PubkyAuthURLSchemeTests: XCTestCase { try await app.handleScannedData(markerlessURL) XCTAssertNil(sheets.activeSheetConfiguration) + + let duplicateRelayURL = "bitkit://pubky-auth/setup?caps=\(PubkyAuthClaim.watchOnlyAccountCapabilities)" + + "&relay=https%3A%2F%2Fa&relay=https%3A%2F%2Fb" + + "&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&x-bitkit-claim=watch-only-account-v1" + try await app.handleScannedData(duplicateRelayURL) + XCTAssertNil(sheets.activeSheetConfiguration) + + let duplicateSecretURL = "bitkit://pubky-auth/setup?caps=\(PubkyAuthClaim.watchOnlyAccountCapabilities)" + + "&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F" + + "&secret=first&secret=second&x-bitkit-claim=watch-only-account-v1" + try await app.handleScannedData(duplicateSecretURL) + XCTAssertNil(sheets.activeSheetConfiguration) + } + + @MainActor + func testNonNodeDeepLinksReleaseAfterStartupGatesWithoutWaitingForLDK() async throws { + let app = AppViewModel(sheetViewModel: SheetViewModel(), navigationViewModel: NavigationViewModel()) + let pubkyURL = try XCTUnwrap(URL(string: "bitkit://pubky-auth/setup?caps=\(PubkyAuthClaim.watchOnlyAccountCapabilities)" + + "&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F" + + "&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&x-bitkit-claim=watch-only-account-v1")) + let httpURL = try XCTUnwrap(URL(string: "https://example.com/article")) + let ringURL = try XCTUnwrap(URL(string: "bitkit://pubky-auth/success")) + let lightningURL = try XCTUnwrap(URL(string: "lightning:lnbc1example")) + + app.retainDeepLink(pubkyURL) + await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { routedURL in + XCTAssertEqual(routedURL, pubkyURL) + } + XCTAssertNil(app.pendingDeepLinkURL) + + app.retainDeepLink(httpURL) + await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { routedURL in + XCTAssertEqual(routedURL, httpURL) + } + XCTAssertNil(app.pendingDeepLinkURL) + + app.retainDeepLink(ringURL) + await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { routedURL in + XCTAssertEqual(routedURL, ringURL) + } + XCTAssertNil(app.pendingDeepLinkURL) + + app.retainDeepLink(lightningURL) + await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { _ in + XCTFail("URLs that need the node must stay pending until LDK is running") + } + XCTAssertEqual(app.pendingDeepLinkURL, lightningURL) + + await app.routePendingDeepLinkIfReady(true, nodeIsRunning: true) { routedURL in + XCTAssertEqual(routedURL, lightningURL) + } + XCTAssertNil(app.pendingDeepLinkURL) } } From a801819bce2d7bf4b582e9e46b1f7a291a9b8b83 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 3 Sep 2026 23:51:34 +0200 Subject: [PATCH 34/40] fix: classify setup routes before node --- Bitkit/ViewModels/AppViewModel.swift | 9 ++++++++ BitkitTests/PubkyAuthURLSchemeTests.swift | 25 +++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 5bd7c549b..af26db622 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -137,6 +137,15 @@ class AppViewModel: ObservableObject { if PubkyRingAuthCallback.parse(url: url) != nil { return false } + if url.scheme?.lowercased() == "bitkit", + url.host?.lowercased() == "pubky-auth", + url.path == "/setup" + { + return false + } + if SamRockSetupRequest.isProtocolURL(url.absoluteString) { + return false + } return !PubkyAuthRequest.isProtocolURL(url.absoluteString) } diff --git a/BitkitTests/PubkyAuthURLSchemeTests.swift b/BitkitTests/PubkyAuthURLSchemeTests.swift index 575784c6b..4c37b4725 100644 --- a/BitkitTests/PubkyAuthURLSchemeTests.swift +++ b/BitkitTests/PubkyAuthURLSchemeTests.swift @@ -106,6 +106,13 @@ final class PubkyAuthURLSchemeTests: XCTestCase { "&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&x-bitkit-claim=watch-only-account-v1")) let httpURL = try XCTUnwrap(URL(string: "https://example.com/article")) let ringURL = try XCTUnwrap(URL(string: "bitkit://pubky-auth/success")) + let malformedPubkyURL = try XCTUnwrap(URL(string: "bitkit://pubky-auth/setup")) + let lightningSamRockURL = try XCTUnwrap( + URL(string: "lightning:https://btcpay.example/plugins/store123/samrock/protocol?setup=btc-chain&otp=abc123") + ) + let lnurlSamRockURL = try XCTUnwrap( + URL(string: "lnurl:https://btcpay.example/plugins/store123/samrock/protocol?setup=btc-chain&otp=abc123") + ) let lightningURL = try XCTUnwrap(URL(string: "lightning:lnbc1example")) app.retainDeepLink(pubkyURL) @@ -126,6 +133,24 @@ final class PubkyAuthURLSchemeTests: XCTestCase { } XCTAssertNil(app.pendingDeepLinkURL) + app.retainDeepLink(malformedPubkyURL) + await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { routedURL in + XCTAssertEqual(routedURL, malformedPubkyURL) + } + XCTAssertNil(app.pendingDeepLinkURL) + + app.retainDeepLink(lightningSamRockURL) + await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { routedURL in + XCTAssertEqual(routedURL, lightningSamRockURL) + } + XCTAssertNil(app.pendingDeepLinkURL) + + app.retainDeepLink(lnurlSamRockURL) + await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { routedURL in + XCTAssertEqual(routedURL, lnurlSamRockURL) + } + XCTAssertNil(app.pendingDeepLinkURL) + app.retainDeepLink(lightningURL) await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { _ in XCTFail("URLs that need the node must stay pending until LDK is running") From 4b07f9727ff844e728158b3ca617d07a841c966d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 4 Sep 2026 00:45:02 +0200 Subject: [PATCH 35/40] fix: ungated BIP21 and BOLT11 links --- Bitkit/ViewModels/AppViewModel.swift | 11 +++++++++++ BitkitTests/PubkyAuthURLSchemeTests.swift | 22 ++++++++++++++++++---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index af26db622..f398d4586 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -146,9 +146,20 @@ class AppViewModel: ObservableObject { if SamRockSetupRequest.isProtocolURL(url.absoluteString) { return false } + if url.scheme?.lowercased() == "bitcoin" { + return false + } + if isBolt11Invoice(url) { + return false + } return !PubkyAuthRequest.isProtocolURL(url.absoluteString) } + private static func isBolt11Invoice(_ url: URL) -> Bool { + let invoice = url.absoluteString.removingLightningSchemes().trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return invoice.hasPrefix("lnbc") || invoice.hasPrefix("lntb") + } + private let lightningService: LightningService private let coreService: CoreService private let sheetViewModel: SheetViewModel diff --git a/BitkitTests/PubkyAuthURLSchemeTests.swift b/BitkitTests/PubkyAuthURLSchemeTests.swift index 4c37b4725..0294192d9 100644 --- a/BitkitTests/PubkyAuthURLSchemeTests.swift +++ b/BitkitTests/PubkyAuthURLSchemeTests.swift @@ -113,7 +113,9 @@ final class PubkyAuthURLSchemeTests: XCTestCase { let lnurlSamRockURL = try XCTUnwrap( URL(string: "lnurl:https://btcpay.example/plugins/store123/samrock/protocol?setup=btc-chain&otp=abc123") ) - let lightningURL = try XCTUnwrap(URL(string: "lightning:lnbc1example")) + let bitcoinURL = try XCTUnwrap(URL(string: "bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq?amount=0.001")) + let bolt11URL = try XCTUnwrap(URL(string: "lightning:lnbc1example")) + let lnurlURL = try XCTUnwrap(URL(string: "lnurl:lnurl1example")) app.retainDeepLink(pubkyURL) await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { routedURL in @@ -151,14 +153,26 @@ final class PubkyAuthURLSchemeTests: XCTestCase { } XCTAssertNil(app.pendingDeepLinkURL) - app.retainDeepLink(lightningURL) + app.retainDeepLink(bitcoinURL) + await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { routedURL in + XCTAssertEqual(routedURL, bitcoinURL) + } + XCTAssertNil(app.pendingDeepLinkURL) + + app.retainDeepLink(bolt11URL) + await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { routedURL in + XCTAssertEqual(routedURL, bolt11URL) + } + XCTAssertNil(app.pendingDeepLinkURL) + + app.retainDeepLink(lnurlURL) await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { _ in XCTFail("URLs that need the node must stay pending until LDK is running") } - XCTAssertEqual(app.pendingDeepLinkURL, lightningURL) + XCTAssertEqual(app.pendingDeepLinkURL, lnurlURL) await app.routePendingDeepLinkIfReady(true, nodeIsRunning: true) { routedURL in - XCTAssertEqual(routedURL, lightningURL) + XCTAssertEqual(routedURL, lnurlURL) } XCTAssertNil(app.pendingDeepLinkURL) } From 69025cdb65a51cc13993b7f1cf63cdcc7fb4612d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 5 Sep 2026 03:53:42 +0200 Subject: [PATCH 36/40] fix: route gifts and identify auth relay --- Bitkit/Models/PubkyAuthRequest.swift | 15 +++++++++++ .../Localization/en.lproj/Localizable.strings | 2 ++ Bitkit/ViewModels/AppViewModel.swift | 5 ++++ .../PubkyAuthApprovalSheet.swift | 26 ++++++++++++++++++- BitkitTests/PubkyAuthRequestTests.swift | 9 +++++++ BitkitTests/PubkyAuthURLSchemeTests.swift | 7 +++++ 6 files changed, 63 insertions(+), 1 deletion(-) diff --git a/Bitkit/Models/PubkyAuthRequest.swift b/Bitkit/Models/PubkyAuthRequest.swift index 7298b32a4..a63d00bf3 100644 --- a/Bitkit/Models/PubkyAuthRequest.swift +++ b/Bitkit/Models/PubkyAuthRequest.swift @@ -64,6 +64,21 @@ struct PubkyAuthRequest { let serviceNames: [String] let bitkitClaim: PubkyAuthClaim? + /// The network origin that receives the authorization. This is a delivery destination, not a service identity. + var relayOrigin: String? { + guard let components = URLComponents(string: relay), + let scheme = components.scheme?.lowercased(), + ["http", "https"].contains(scheme), + let host = components.host?.lowercased(), + !host.isEmpty + else { + return nil + } + + let port = components.port.map { ":\($0)" } ?? "" + return "\(scheme)://\(host)\(port)" + } + static func isProtocolURL(_ value: String) -> Bool { URLComponents(string: normalizedProtocolURL(value).trimmingCharacters(in: .whitespacesAndNewlines))?.scheme?.lowercased() == "pubkyauth" } diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 2344930f8..de9fd1c0d 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -701,10 +701,12 @@ "pubky_auth__watch_only_account_name_error" = "Enter an account name between 1 and 64 characters."; "pubky_auth__watch_only_intro_approve" = "Approve"; "pubky_auth__watch_only_intro_description" = "To earn, you need to share a watch-only Bitcoin account with Paykit. It can view sales activity, but cannot spend funds."; +"pubky_auth__watch_only_intro_relay" = "Your authorization will be delivered to {relay}."; "pubky_auth__watch_only_intro_nav_title" = "Earn"; "pubky_auth__watch_only_intro_title" = "EARN BITCOIN\nFROM YOUR\nCONTENT"; "pubky_auth__watch_only_account_xpub_error" = "Bitkit could not create a valid account xpub."; "pubky_auth__trust_warning" = "Make sure you trust the service, browser, or device before authorizing with your pubky."; +"pubky_auth__authorization_relay" = "AUTHORIZATION RELAY"; "pubky_auth__authorizing" = "Authorizing..."; "pubky_auth__success_title" = "Authorization Successful"; "pubky_auth__success_prefix" = "You authorized with pubky "; diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index f398d4586..fea0add42 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -152,6 +152,11 @@ class AppViewModel: ObservableObject { if isBolt11Invoice(url) { return false } + if url.scheme?.lowercased() == "bitkit", + url.host?.lowercased().hasPrefix("gift-") == true + { + return false + } return !PubkyAuthRequest.isProtocolURL(url.absoluteString) } diff --git a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift index 2d79b2076..dae67f596 100644 --- a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift +++ b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift @@ -131,7 +131,7 @@ struct PubkyAuthApprovalSheet: View { SheetIntro( navTitle: t("pubky_auth__watch_only_intro_nav_title"), title: t("pubky_auth__watch_only_intro_title"), - description: t("pubky_auth__watch_only_intro_description"), + description: watchOnlyConsentDescription, image: "coin-stack", continueText: t("pubky_auth__watch_only_intro_approve"), cancelText: t("common__cancel"), @@ -231,6 +231,11 @@ struct PubkyAuthApprovalSheet: View { descriptionText .padding(.bottom, 32) + if let relayOrigin = config.request.relayOrigin { + relayOriginSection(relayOrigin) + .padding(.bottom, 24) + } + permissionsSection Spacer(minLength: 32) @@ -260,6 +265,25 @@ struct PubkyAuthApprovalSheet: View { .lineSpacing(4) } + private var watchOnlyConsentDescription: String { + let description = t("pubky_auth__watch_only_intro_description") + guard let relayOrigin = config.request.relayOrigin else { return description } + + return description + "\n\n" + t( + "pubky_auth__watch_only_intro_relay", + variables: ["relay": relayOrigin] + ) + } + + private func relayOriginSection(_ relayOrigin: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("pubky_auth__authorization_relay"), textColor: .white64) + BodySSBText(relayOrigin) + .accessibilityIdentifier("PubkyAuthRelayOrigin") + CustomDivider(color: .white10) + } + } + private var successDescriptionText: some View { BodyMText( t("pubky_auth__success_prefix") + "" + truncatedPublicKey + "" diff --git a/BitkitTests/PubkyAuthRequestTests.swift b/BitkitTests/PubkyAuthRequestTests.swift index aba6076fe..583172db0 100644 --- a/BitkitTests/PubkyAuthRequestTests.swift +++ b/BitkitTests/PubkyAuthRequestTests.swift @@ -26,6 +26,15 @@ final class PubkyAuthRequestTests: XCTestCase { XCTAssertEqual(request.capabilities, PubkyAuthClaim.watchOnlyAccountCapabilities) } + func testRelayOriginShowsOnlyTheAuthorizationDestination() throws { + let url = "bitkit://pubky-auth/setup?caps=\(PubkyAuthClaim.watchOnlyAccountCapabilities)" + + "&relay=https%3A%2F%2FRelay.Example%3A8443%2Finbox%2F&secret=\(secret)&x-bitkit-claim=watch-only-account-v1" + + let request = try PubkyAuthRequest.parse(url: url) + + XCTAssertEqual(request.relayOrigin, "https://relay.example:8443") + } + func testProtocolUrlRejectsBitkitSpecificSetupHandoffWithoutClaimMarker() { let url = "bitkit://pubky-auth/setup?caps=\(PubkyAuthClaim.watchOnlyAccountCapabilities)" + "&relay=\(relay)&secret=\(secret)" diff --git a/BitkitTests/PubkyAuthURLSchemeTests.swift b/BitkitTests/PubkyAuthURLSchemeTests.swift index 0294192d9..49b97f8f7 100644 --- a/BitkitTests/PubkyAuthURLSchemeTests.swift +++ b/BitkitTests/PubkyAuthURLSchemeTests.swift @@ -115,6 +115,7 @@ final class PubkyAuthURLSchemeTests: XCTestCase { ) let bitcoinURL = try XCTUnwrap(URL(string: "bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq?amount=0.001")) let bolt11URL = try XCTUnwrap(URL(string: "lightning:lnbc1example")) + let giftURL = try XCTUnwrap(URL(string: "bitkit://gift-code-1000")) let lnurlURL = try XCTUnwrap(URL(string: "lnurl:lnurl1example")) app.retainDeepLink(pubkyURL) @@ -165,6 +166,12 @@ final class PubkyAuthURLSchemeTests: XCTestCase { } XCTAssertNil(app.pendingDeepLinkURL) + app.retainDeepLink(giftURL) + await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { routedURL in + XCTAssertEqual(routedURL, giftURL) + } + XCTAssertNil(app.pendingDeepLinkURL) + app.retainDeepLink(lnurlURL) await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { _ in XCTFail("URLs that need the node must stay pending until LDK is running") From 02eb8d49790216870f1c1747f8ceaafb8568b43a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 5 Sep 2026 04:08:39 +0200 Subject: [PATCH 37/40] fix: retain scene-delivered deep links --- Bitkit/AppScene.swift | 21 ++++++++++++++++++ Bitkit/BitkitApp.swift | 1 + Bitkit/SceneDelegate.swift | 32 ++++++++++++++++++++++++++++ BitkitTests/SceneDelegateTests.swift | 25 ++++++++++++++++++++++ 4 files changed, 79 insertions(+) create mode 100644 BitkitTests/SceneDelegateTests.swift diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 5a37a5d9f..790311d36 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -284,6 +284,10 @@ struct AppScene: View { isPinVerified = true } + if let url = DeepLinkRouter.shared.consume() { + app.retainDeepLink(url) + } + // Listen for quick action notifications NotificationCenter.default.addObserver( forName: .quickActionSelected, @@ -292,6 +296,13 @@ struct AppScene: View { ) { notification in handleQuickAction(notification) } + NotificationCenter.default.addObserver( + forName: .deepLinkReceived, + object: nil, + queue: .main + ) { notification in + handleDeepLinkNotification(notification) + } } .onReceive(BackupService.shared.backupFailurePublisher) { intervalMinutes in handleBackupFailure(intervalMinutes: intervalMinutes) @@ -302,6 +313,16 @@ struct AppScene: View { } } + private func handleDeepLinkNotification(_ notification: Notification) { + if let retainedURL = DeepLinkRouter.shared.consume() { + app.retainDeepLink(retainedURL) + return + } + if let receivedURL = notification.object as? URL { + app.retainDeepLink(receivedURL) + } + } + private var mainContent: some View { ZStack { if Env.isTrezorEmulatorTesting { diff --git a/Bitkit/BitkitApp.swift b/Bitkit/BitkitApp.swift index be9e09e1a..86f857eef 100644 --- a/Bitkit/BitkitApp.swift +++ b/Bitkit/BitkitApp.swift @@ -5,6 +5,7 @@ import SwiftUI /// Communication bridge between delegates and SwiftUI views extension Notification.Name { static let quickActionSelected = Notification.Name("quickActionSelected") + static let deepLinkReceived = Notification.Name("deepLinkReceived") } class AppDelegate: NSObject, UIApplicationDelegate { diff --git a/Bitkit/SceneDelegate.swift b/Bitkit/SceneDelegate.swift index 51e36c932..605aaace9 100644 --- a/Bitkit/SceneDelegate.swift +++ b/Bitkit/SceneDelegate.swift @@ -1,6 +1,21 @@ import SwiftUI import UIKit +final class DeepLinkRouter { + static let shared = DeepLinkRouter() + + private var pendingURL: URL? + + func retain(_ url: URL) { + pendingURL = url + } + + func consume() -> URL? { + defer { pendingURL = nil } + return pendingURL + } +} + // MARK: - Scene Delegate for Quick Actions /// Handles scene lifecycle and quick actions for SwiftUI apps @@ -8,6 +23,7 @@ class SceneDelegate: NSObject, UIWindowSceneDelegate { // MARK: - Quick Action State var savedShortCutItem: UIApplicationShortcutItem? + var savedDeepLinkURL: URL? // MARK: - Scene Connection @@ -16,6 +32,7 @@ class SceneDelegate: NSObject, UIWindowSceneDelegate { if let shortcutItem = connectionOptions.shortcutItem { savedShortCutItem = shortcutItem } + savedDeepLinkURL = connectionOptions.urlContexts.first?.url } // MARK: - Scene Activation @@ -26,6 +43,10 @@ class SceneDelegate: NSObject, UIWindowSceneDelegate { handleQuickAction(shortcutItem) savedShortCutItem = nil } + if let url = savedDeepLinkURL { + forwardDeepLink(url) + savedDeepLinkURL = nil + } } // MARK: - Quick Action Handling (App Running) @@ -40,6 +61,12 @@ class SceneDelegate: NSObject, UIWindowSceneDelegate { completionHandler(true) } + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + for context in URLContexts { + forwardDeepLink(context.url) + } + } + // MARK: - Quick Action Processing /// Process quick action and notify SwiftUI views @@ -47,4 +74,9 @@ class SceneDelegate: NSObject, UIWindowSceneDelegate { let userInfo = ["shortcutType": shortcutItem.type] NotificationCenter.default.post(name: .quickActionSelected, object: nil, userInfo: userInfo) } + + func forwardDeepLink(_ url: URL) { + DeepLinkRouter.shared.retain(url) + NotificationCenter.default.post(name: .deepLinkReceived, object: url) + } } diff --git a/BitkitTests/SceneDelegateTests.swift b/BitkitTests/SceneDelegateTests.swift new file mode 100644 index 000000000..7a18676e8 --- /dev/null +++ b/BitkitTests/SceneDelegateTests.swift @@ -0,0 +1,25 @@ +@testable import Bitkit +import XCTest + +final class SceneDelegateTests: XCTestCase { + func testForwardsDeepLinksToSwiftUIRetentionPath() throws { + let delegate = SceneDelegate() + let url = try XCTUnwrap(URL(string: "bitkit://pubky-auth/setup?caps=example")) + _ = DeepLinkRouter.shared.consume() + let forwarded = expectation(description: "deep link forwarded") + let observer = NotificationCenter.default.addObserver( + forName: .deepLinkReceived, + object: nil, + queue: nil + ) { notification in + XCTAssertEqual(notification.object as? URL, url) + forwarded.fulfill() + } + defer { NotificationCenter.default.removeObserver(observer) } + + delegate.forwardDeepLink(url) + + wait(for: [forwarded], timeout: 1) + XCTAssertEqual(DeepLinkRouter.shared.consume(), url) + } +} From 89259161488a01495e6c2ad6ea68b9c75a52b712 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 5 Sep 2026 04:12:09 +0200 Subject: [PATCH 38/40] fix: forward app-delivered deep links --- Bitkit/BitkitApp.swift | 9 +++++++++ Bitkit/SceneDelegate.swift | 8 ++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/Bitkit/BitkitApp.swift b/Bitkit/BitkitApp.swift index 86f857eef..268d511bd 100644 --- a/Bitkit/BitkitApp.swift +++ b/Bitkit/BitkitApp.swift @@ -40,6 +40,15 @@ class AppDelegate: NSObject, UIApplicationDelegate { return config } + func application( + _ application: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] = [:] + ) -> Bool { + DeepLinkRouter.shared.forward(url) + return true + } + // MARK: - App Termination func applicationWillTerminate(_ application: UIApplication) { diff --git a/Bitkit/SceneDelegate.swift b/Bitkit/SceneDelegate.swift index 605aaace9..570d4e637 100644 --- a/Bitkit/SceneDelegate.swift +++ b/Bitkit/SceneDelegate.swift @@ -10,6 +10,11 @@ final class DeepLinkRouter { pendingURL = url } + func forward(_ url: URL) { + retain(url) + NotificationCenter.default.post(name: .deepLinkReceived, object: url) + } + func consume() -> URL? { defer { pendingURL = nil } return pendingURL @@ -76,7 +81,6 @@ class SceneDelegate: NSObject, UIWindowSceneDelegate { } func forwardDeepLink(_ url: URL) { - DeepLinkRouter.shared.retain(url) - NotificationCenter.default.post(name: .deepLinkReceived, object: url) + DeepLinkRouter.shared.forward(url) } } From 4d0a0b659e57fa53c1d9e62dc9822c57c9d3f188 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 8 Sep 2026 14:56:06 +0200 Subject: [PATCH 39/40] chore: format deep-link files --- Bitkit/MainNavView.swift | 72 +++++++++++++++++++++++----- Bitkit/Models/PubkyAuthRequest.swift | 8 +++- 2 files changed, 66 insertions(+), 14 deletions(-) diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index da674bfa3..a805282eb 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -436,13 +436,29 @@ struct MainNavView: View { ContactsIntroView() } case .contactsIntro: - if isPaykitUIActive { ContactsIntroView() } else { ComingSoonScreen() } + if isPaykitUIActive { + ContactsIntroView() + } else { + ComingSoonScreen() + } case let .contactDetail(publicKey): - if isPaykitUIActive { ContactDetailView(publicKey: publicKey) } else { paykitDisabledRedirectView } + if isPaykitUIActive { + ContactDetailView(publicKey: publicKey) + } else { + paykitDisabledRedirectView + } case let .contactSaved(publicKey): - if isPaykitUIActive { ContactDetailView(publicKey: publicKey, showsDeleteAction: true) } else { paykitDisabledRedirectView } + if isPaykitUIActive { + ContactDetailView(publicKey: publicKey, showsDeleteAction: true) + } else { + paykitDisabledRedirectView + } case let .contactActivity(publicKey): - if isPaykitUIActive { ContactActivityView(publicKey: publicKey) } else { paykitDisabledRedirectView } + if isPaykitUIActive { + ContactActivityView(publicKey: publicKey) + } else { + paykitDisabledRedirectView + } case let .assignActivityContact(activityId, walletId): if isPaykitUIActive { AssignActivityContactView(activityId: activityId, walletId: walletId) @@ -471,9 +487,17 @@ struct MainNavView: View { ContactImportSelectView(contacts: contactsManager.pendingImportContacts) } case let .addContact(publicKey): - if isPaykitUIActive { AddContactView(publicKey: publicKey) } else { paykitDisabledRedirectView } + if isPaykitUIActive { + AddContactView(publicKey: publicKey) + } else { + paykitDisabledRedirectView + } case let .editContact(publicKey): - if isPaykitUIActive { EditContactView(publicKey: publicKey) } else { paykitDisabledRedirectView } + if isPaykitUIActive { + EditContactView(publicKey: publicKey) + } else { + paykitDisabledRedirectView + } case .profile: if !isPaykitUIActive { ComingSoonScreen() @@ -489,17 +513,41 @@ struct MainNavView: View { ProfileIntroView() } case .profileIntro: - if isPaykitUIActive { ProfileIntroView() } else { ComingSoonScreen() } + if isPaykitUIActive { + ProfileIntroView() + } else { + ComingSoonScreen() + } case .pubkyChoice: - if isPaykitUIActive { PubkyChoiceView() } else { paykitDisabledRedirectView } + if isPaykitUIActive { + PubkyChoiceView() + } else { + paykitDisabledRedirectView + } case .createProfile: - if isPaykitUIActive { CreateProfileView() } else { paykitDisabledRedirectView } + if isPaykitUIActive { + CreateProfileView() + } else { + paykitDisabledRedirectView + } case .editProfile: - if isPaykitUIActive { EditProfileView() } else { paykitDisabledRedirectView } + if isPaykitUIActive { + EditProfileView() + } else { + paykitDisabledRedirectView + } case .payContacts: - if isPaykitUIActive { PayContactsView() } else { paykitDisabledRedirectView } + if isPaykitUIActive { + PayContactsView() + } else { + paykitDisabledRedirectView + } case .paymentRequests: - if isPaykitUIActive { PaymentRequestsView() } else { paykitDisabledRedirectView } + if isPaykitUIActive { + PaymentRequestsView() + } else { + paykitDisabledRedirectView + } // Shop case .shopIntro: ShopIntro() diff --git a/Bitkit/Models/PubkyAuthRequest.swift b/Bitkit/Models/PubkyAuthRequest.swift index a63d00bf3..dab512605 100644 --- a/Bitkit/Models/PubkyAuthRequest.swift +++ b/Bitkit/Models/PubkyAuthRequest.swift @@ -44,8 +44,12 @@ struct PubkyAuthPermission { var displayAccess: String { var levels: [String] = [] - if accessLevel.contains("r") { levels.append("READ") } - if accessLevel.contains("w") { levels.append("WRITE") } + if accessLevel.contains("r") { + levels.append("READ") + } + if accessLevel.contains("w") { + levels.append("WRITE") + } return levels.joined(separator: ", ") } } From c2de3f05610f6b586b7037bf7686db3c3a13c219 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 8 Sep 2026 21:04:32 +0200 Subject: [PATCH 40/40] docs: clarify pubky link contract --- journeys/pubky-auth/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/journeys/pubky-auth/README.md b/journeys/pubky-auth/README.md index e014c2704..dca0d0bd0 100644 --- a/journeys/pubky-auth/README.md +++ b/journeys/pubky-auth/README.md @@ -1,6 +1,6 @@ # Pubky auth -This suite covers the uniquely targetable `bitkit://pubky-auth/setup` OS handoff into Bitkit. The wrapper carries the Paykit grant-auth requester fields and normalizes to `pubkyauth://signin_grant`; raw Pubky auth and signup requests remain supported through QR scanning and clipboard paste. +This suite covers the uniquely targetable `bitkit://pubky-auth/setup` OS handoff into Bitkit. The wrapper carries the Paykit grant-auth requester fields, normalizes to `pubkyauth://signin_grant`, and is the only link form that receives Bitkit claim validation. `lightning:`/`lnurl*:`-prefixed raw `pubkyauth://` auth and signup requests are also accepted from OS links, matching scanner and clipboard-paste behavior. It stops at explicit watch-only consent and never authorizes or exports account material. Bitkit retains links delivered during startup, restoration, or PIN entry and presents consent only after the main wallet UI is available.