From d769ad1908b63362b0512ba0f6c0b40b7d0228f0 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 24 Jul 2026 11:07:48 +0200 Subject: [PATCH 1/5] fix: remove obsolete contact save hooks --- Bitkit/Components/NavigationBar.swift | 5 +---- Bitkit/Views/Contacts/AddContactView.swift | 8 ++------ Bitkit/Views/Contacts/ContactDetailView.swift | 7 ++----- 3 files changed, 5 insertions(+), 15 deletions(-) diff --git a/Bitkit/Components/NavigationBar.swift b/Bitkit/Components/NavigationBar.swift index 23a0977fb..36fb13dbb 100644 --- a/Bitkit/Components/NavigationBar.swift +++ b/Bitkit/Components/NavigationBar.swift @@ -10,7 +10,6 @@ struct NavigationBar: View { let action: AnyView? let icon: String? let onBack: (() -> Void)? - let backAccessibilityIdentifier: String init( title: String, @@ -18,7 +17,6 @@ struct NavigationBar: View { showMenuButton: Bool = true, action: AnyView? = nil, icon: String? = nil, - backAccessibilityIdentifier: String = "NavigationBack", onBack: (() -> Void)? = nil ) { self.title = title @@ -27,7 +25,6 @@ struct NavigationBar: View { self.action = action self.icon = icon self.onBack = onBack - self.backAccessibilityIdentifier = backAccessibilityIdentifier } var body: some View { @@ -51,7 +48,7 @@ struct NavigationBar: View { .foregroundColor(.textPrimary) .frame(width: 24, height: 24) } - .accessibilityIdentifier(backAccessibilityIdentifier) + .accessibilityIdentifier("NavigationBack") } else { Spacer() .frame(width: 24, height: 24) diff --git a/Bitkit/Views/Contacts/AddContactView.swift b/Bitkit/Views/Contacts/AddContactView.swift index c47a842de..7c1e02828 100644 --- a/Bitkit/Views/Contacts/AddContactView.swift +++ b/Bitkit/Views/Contacts/AddContactView.swift @@ -37,11 +37,8 @@ struct AddContactView: View { var body: some View { VStack(spacing: 0) { - NavigationBar( - title: t("contacts__add_title"), - backAccessibilityIdentifier: "AddContactDiscard" - ) - .padding(.horizontal, 16) + NavigationBar(title: t("contacts__add_title")) + .padding(.horizontal, 16) if isLoading { loadingContent @@ -249,7 +246,6 @@ struct AddContactView: View { existingProfile: fetchedProfile, ownPublicKey: pubkyProfile.publicKey ) - app.toast(type: .success, title: t("contacts__add_success"), accessibilityIdentifier: "ContactSavedToast") navigation.path = [.contacts, .contactSaved(publicKey: normalizedPublicKey)] } catch { Logger.error("Failed to save contact: \(error)", context: "AddContactView") diff --git a/Bitkit/Views/Contacts/ContactDetailView.swift b/Bitkit/Views/Contacts/ContactDetailView.swift index ccdf90c52..5d4ee1da7 100644 --- a/Bitkit/Views/Contacts/ContactDetailView.swift +++ b/Bitkit/Views/Contacts/ContactDetailView.swift @@ -20,11 +20,8 @@ struct ContactDetailView: View { var body: some View { VStack(spacing: 0) { - NavigationBar( - title: t(showsDeleteAction ? "contacts__saved_title" : "contacts__detail_title"), - backAccessibilityIdentifier: showsDeleteAction ? "ContactsAddButton" : "NavigationBack" - ) - .padding(.horizontal, 16) + NavigationBar(title: t(showsDeleteAction ? "contacts__saved_title" : "contacts__detail_title")) + .padding(.horizontal, 16) if isLoading { loadingContent From f923160ebb9e9741099bebf413f5497ee7f2bdfc Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Fri, 24 Jul 2026 15:44:10 +0200 Subject: [PATCH 2/5] feat: reuse pubky ring identities --- Bitkit/AppScene.swift | 54 +- .../icons/key.imageset/Contents.json | 16 + .../icons/key.imageset/key.svg | 5 + Bitkit/Bitkit.entitlements | 1 + Bitkit/Info.plist | 2 + Bitkit/Managers/PubkyProfileManager.swift | 618 +++++++++++++++++- Bitkit/Models/SharedPubkyIdentity.swift | 114 ++++ .../Localization/en.lproj/Localizable.strings | 5 +- Bitkit/Services/PubkyService.swift | 70 +- .../Services/SharedPubkyIdentityVault.swift | 306 +++++++++ Bitkit/Styles/Colors.swift | 2 +- Bitkit/Styles/TextStyle.swift | 7 +- Bitkit/Utilities/AppReset.swift | 23 + Bitkit/Utilities/Keychain.swift | 3 + Bitkit/ViewModels/AppViewModel.swift | 11 +- Bitkit/Views/Profile/PubkyChoiceView.swift | 305 ++++----- .../PubkyAuthApprovalSheet.swift | 8 +- BitkitTests/PubkyProfileManagerTests.swift | 49 +- BitkitTests/SharedPubkyIdentityTests.swift | 405 ++++++++++++ changelog.d/next/shared-pubky-ring.added.md | 1 + 20 files changed, 1784 insertions(+), 221 deletions(-) create mode 100644 Bitkit/Assets.xcassets/icons/key.imageset/Contents.json create mode 100644 Bitkit/Assets.xcassets/icons/key.imageset/key.svg create mode 100644 Bitkit/Models/SharedPubkyIdentity.swift create mode 100644 Bitkit/Services/SharedPubkyIdentityVault.swift create mode 100644 BitkitTests/SharedPubkyIdentityTests.swift create mode 100644 changelog.d/next/shared-pubky-ring.added.md diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index d5f9d5a0f..f9427a82c 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -3,6 +3,29 @@ import LDKNode import SwiftUI import UserNotifications +enum OrphanedKeychainCleanup { + static func perform( + hasNativeKeychain: Bool, + hasOrphanedRNKeychain: Bool, + deleteBitkitSharedIdentities: () throws -> Void, + wipePrivateKeychain: () throws -> Void, + cleanupRNKeychain: () -> Void + ) throws { + guard hasNativeKeychain || hasOrphanedRNKeychain else { + return + } + + // A reinstall must never orphan a shared Bitkit credential. Ring-owned + // accounts are outside this deletion's scope. + try deleteBitkitSharedIdentities() + try wipePrivateKeychain() + + if hasOrphanedRNKeychain { + cleanupRNKeychain() + } + } +} + struct AppScene: View { @Environment(\.scenePhase) var scenePhase @EnvironmentObject private var session: SessionManager @@ -485,7 +508,7 @@ struct AppScene: View { /// Handle orphaned keychain entries from previous app installs. /// If the installation marker doesn't exist but keychain has data, the app was reinstalled /// and the keychain data is orphaned (corresponding wallet data was deleted with the app). - private func handleOrphanedKeychain() { + private func handleOrphanedKeychain() throws { // If marker exists, app was installed before - keychain is valid if InstallationMarker.exists() { Logger.debug("Installation marker exists, skipping orphaned keychain check", context: "AppScene") @@ -500,26 +523,30 @@ struct AppScene: View { if hasNativeKeychain || hasOrphanedRNKeychain { Logger.warn("Orphaned keychain detected, wiping", context: "AppScene") - try? Keychain.wipeEntireKeychain() - - if hasOrphanedRNKeychain { - MigrationsService.shared.cleanupRNKeychain() - } + try OrphanedKeychainCleanup.perform( + hasNativeKeychain: hasNativeKeychain, + hasOrphanedRNKeychain: hasOrphanedRNKeychain, + deleteBitkitSharedIdentities: { + try SharedPubkyIdentityVault.deleteAllBitkitIdentities() + }, + wipePrivateKeychain: { + try Keychain.wipeEntireKeychain() + }, + cleanupRNKeychain: { + MigrationsService.shared.cleanupRNKeychain() + } + ) } // Create marker for this installation - do { - try InstallationMarker.create() - } catch { - Logger.error("Failed to create installation marker: \(error)", context: "AppScene") - } + try InstallationMarker.create() } @Sendable private func setupTask() async { do { // Handle orphaned keychain before anything else - handleOrphanedKeychain() + try handleOrphanedKeychain() await checkAndPerformRNMigration() try wallet.setWalletExistsState() @@ -662,6 +689,9 @@ struct AppScene: View { } if newPhase == .active { + Task { + await pubkyProfile.validateSharedIdentitySourceIfNeeded() + } // Reconnect a known hardware device so its connection indicator turns green again; if isPinVerified || !settings.pinEnabled { Task { await trezorManager.autoReconnect() } diff --git a/Bitkit/Assets.xcassets/icons/key.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/key.imageset/Contents.json new file mode 100644 index 000000000..554d63e1a --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/key.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "key.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Bitkit/Assets.xcassets/icons/key.imageset/key.svg b/Bitkit/Assets.xcassets/icons/key.imageset/key.svg new file mode 100644 index 000000000..2b6335608 --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/key.imageset/key.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Bitkit/Bitkit.entitlements b/Bitkit/Bitkit.entitlements index 0898d896c..6c673bbaf 100644 --- a/Bitkit/Bitkit.entitlements +++ b/Bitkit/Bitkit.entitlements @@ -20,6 +20,7 @@ $(AppIdentifierPrefix)to.bitkit.signet $(AppIdentifierPrefix)to.bitkit.testnet $(AppIdentifierPrefix)to.bitkit.regtest + $(AppIdentifierPrefix)pubky.shared diff --git a/Bitkit/Info.plist b/Bitkit/Info.plist index 5c1c3f538..e51ad595c 100644 --- a/Bitkit/Info.plist +++ b/Bitkit/Info.plist @@ -2,6 +2,8 @@ + SharedPubkyKeychainAccessGroup + $(AppIdentifierPrefix)pubky.shared CFBundleURLTypes diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 5893636fe..5ff36c8bd 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -121,6 +121,37 @@ private enum PubkyProfileManagerError: LocalizedError { } } +private actor PubkyIdentityLifecycleLock { + private var isLocked = false + private var waiters: [CheckedContinuation] = [] + + func withLock(_ operation: () async throws -> T) async rethrows -> T { + await lock() + defer { unlock() } + return try await operation() + } + + private func lock() async { + guard isLocked else { + isLocked = true + return + } + + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + private func unlock() { + guard !waiters.isEmpty else { + isLocked = false + return + } + + waiters.removeFirst().resume() + } +} + @MainActor class PubkyProfileManager: ObservableObject { enum SessionInitializationResult: Equatable { @@ -138,9 +169,19 @@ class PubkyProfileManager: ObservableObject { @Published var sessionRestorationFailed = false @Published private(set) var cachedName: String? @Published private(set) var cachedImageUri: String? + @Published private(set) var sharedRingIdentities: [SharedPubkyIdentityOption] = [] + @Published private(set) var isLoadingSharedRingIdentities = false + @Published private(set) var isSharedIdentityDiscoveryAvailable = true + private nonisolated static let identityLifecycleLock = PubkyIdentityLifecycleLock() private var activeAuthAttemptID: UUID? + nonisolated static func withIdentityLifecycleLock( + _ operation: () async throws -> T + ) async rethrows -> T { + try await identityLifecycleLock.withLock(operation) + } + init() { cachedName = UserDefaults.standard.string(forKey: Self.cachedNameKey) cachedImageUri = UserDefaults.standard.string(forKey: Self.cachedImageUriKey) @@ -150,20 +191,35 @@ class PubkyProfileManager: ObservableObject { /// Initializes Paykit and restores any persisted session. func initialize() async { + await Self.withIdentityLifecycleLock { + await self.initializeLocked() + } + } + + private func initializeLocked() async { isInitialized = false initializationErrorMessage = nil sessionRestorationFailed = false let result: SessionInitializationResult - do { - result = try await Task.detached { - try await Self.initializePersistedSession() - }.value - } catch { - Logger.error("Failed to initialize paykit: \(error)", context: "PubkyProfileManager") - authState = .idle - initializationErrorMessage = error.localizedDescription - return + if sharedIdentitySourceIsUnavailable() { + do { + try await Self.clearSharedIdentitySession() + } catch { + Logger.error("Failed to clear unavailable shared Pubky session: \(error)", context: "PubkyProfileManager") + } + result = .restorationFailed + } else { + do { + result = try await Task.detached { + try await Self.initializePersistedSession() + }.value + } catch { + Logger.error("Failed to initialize paykit: \(error)", context: "PubkyProfileManager") + authState = .idle + initializationErrorMessage = error.localizedDescription + return + } } switch result { @@ -174,6 +230,7 @@ class PubkyProfileManager: ObservableObject { publicKey = pk authState = .authenticated Logger.info("Paykit session restored for \(pk)", context: "PubkyProfileManager") + await reconcileBitkitOwnedIdentityIfNeededLocked(publicKey: pk) Task { await loadProfile() } case .restorationFailed: clearAuthenticatedState() @@ -252,8 +309,37 @@ class PubkyProfileManager: ObservableObject { existingImageUrl: String? = nil, avatarImage: UIImage? = nil ) async throws { + try await Self.withIdentityLifecycleLock { + try await self.createIdentityLocked( + name: name, + bio: bio, + links: links, + tags: tags, + existingImageUrl: existingImageUrl, + avatarImage: avatarImage + ) + } + } + + private func createIdentityLocked( + name: String, + bio: String, + links: [PubkyProfileLink], + tags: [String], + existingImageUrl: String?, + avatarImage: UIImage? + ) async throws { + try Task.checkCancellation() + guard publicKey == nil, + try SharedPubkyIdentityReferenceStore.load() == nil, + try Keychain.loadString(key: .paykitSession)?.isEmpty != false, + try Keychain.loadString(key: .pubkySecretKey)?.isEmpty != false + else { + throw PubkyServiceError.authFailed("A Pubky identity is already recoverable") + } let (publicKeyZ32, secretKeyHex) = try await deriveKeys() + try Task.checkCancellation() _ = try await Task.detached { let homegate = try await Self.fetchHomegateSignupCode() @@ -273,6 +359,7 @@ class PubkyProfileManager: ObservableObject { }.value do { + try Task.checkCancellation() var avatarUri: String? if let avatarImage { avatarUri = try await uploadAvatar(image: avatarImage) @@ -302,6 +389,8 @@ class PubkyProfileManager: ObservableObject { authState = .authenticated profile = createdProfile cacheProfileMetadata(createdProfile) + try SharedPubkyIdentityReferenceStore.delete() + await reconcileBitkitOwnedIdentityIfNeededLocked(publicKey: publicKeyZ32) } catch { try? Keychain.delete(key: .pubkySecretKey) try? Keychain.delete(key: .paykitSession) @@ -346,6 +435,20 @@ class PubkyProfileManager: ObservableObject { } func deleteProfile() async throws { + try await Self.withIdentityLifecycleLock { + try await self.deleteProfileLocked() + } + } + + private func deleteProfileLocked() async throws { + let deletedPublicKey = publicKey + let ownsIdentity = hasLocalSecretKeyForCurrentProfile + + // Remove and verify the interoperability mirror before touching the canonical private identity. + if ownsIdentity, let deletedPublicKey { + try SharedPubkyIdentityVault.deleteBitkitIdentity(pubky: deletedPublicKey) + } + try await Self.removePrivatePaykitEndpoints(context: "PubkyProfileManager.deleteProfile") do { try await Task.detached { @@ -359,7 +462,10 @@ class PubkyProfileManager: ObservableObject { Logger.info("Bitkit profile storage already missing, continuing sign out", context: "PubkyProfileManager") } - try await signOut(cleanPrivatePaykitEndpoints: false) + try await signOutLocked(cleanPrivatePaykitEndpoints: false) + if ownsIdentity, let deletedPublicKey { + try SharedPubkyIdentityVault.deleteBitkitIdentity(pubky: deletedPublicKey) + } } private func writeProfile( @@ -383,6 +489,9 @@ class PubkyProfileManager: ObservableObject { } static func isRingAvailable() -> Bool { + // This is an availability hint, not an identity proof: URL schemes can be claimed by + // another app. Shared-Keychain entitlement and payload validation remain the trust + // boundary. A source-authenticated liveness handshake is a follow-up release hardening. guard let url = URL(string: "pubkyauth://check") else { return false } @@ -390,6 +499,238 @@ class PubkyProfileManager: ObservableObject { return UIApplication.shared.canOpenURL(url) } + // MARK: - Shared Identity Discovery + + func refreshSharedRingIdentities() async { + guard publicKey == nil else { + sharedRingIdentities = [] + return + } + + guard Self.isRingAvailable() else { + sharedRingIdentities = [] + isSharedIdentityDiscoveryAvailable = true + return + } + + isLoadingSharedRingIdentities = true + defer { isLoadingSharedRingIdentities = false } + + do { + let references = try await Task.detached { + try SharedPubkyIdentityVault.list(source: .ring) + }.value + var options: [SharedPubkyIdentityOption] = [] + for reference in references { + guard let prefixedPubky = SharedPubkyKeyFormat.prefixed(reference.pubky) else { + continue + } + let profile = await fetchRemoteProfile(publicKey: prefixedPubky) + ?? PubkyProfile.placeholder(publicKey: prefixedPubky) + options.append(SharedPubkyIdentityOption(reference: reference, profile: profile)) + } + + sharedRingIdentities = options.sorted { + let lhsName = $0.profile.name.localizedLowercase + let rhsName = $1.profile.name.localizedLowercase + return lhsName == rhsName + ? $0.reference.pubky < $1.reference.pubky + : lhsName < rhsName + } + isSharedIdentityDiscoveryAvailable = true + } catch SharedPubkyIdentityError.missingEntitlement { + sharedRingIdentities = [] + isSharedIdentityDiscoveryAvailable = false + Logger.info("Shared Pubky Keychain entitlement is not available yet", context: "PubkyProfileManager") + } catch { + sharedRingIdentities = [] + isSharedIdentityDiscoveryAvailable = false + Logger.warn("Failed to discover Pubky Ring identities: \(error)", context: "PubkyProfileManager") + } + } + + @discardableResult + func useSharedRingIdentity(_ option: SharedPubkyIdentityOption) async throws -> String { + try await Self.withIdentityLifecycleLock { + try await self.useSharedRingIdentityLocked(option) + } + } + + private func useSharedRingIdentityLocked(_ option: SharedPubkyIdentityOption) async throws -> String { + guard publicKey == nil, + try SharedPubkyIdentityReferenceStore.load() == nil, + try Keychain.loadString(key: .paykitSession)?.isEmpty != false, + try Keychain.loadString(key: .pubkySecretKey)?.isEmpty != false + else { + throw PubkyServiceError.authFailed("A Pubky identity is already recoverable") + } + guard Self.isRingAvailable() else { + throw SharedPubkyIdentityError.sourceUnavailable + } + + authState = .authenticating + do { + try Task.checkCancellation() + let secretKey = try await Task.detached { + try SharedPubkyIdentityVault.loadCredential(reference: option.reference) + }.value + try Task.checkCancellation() + let prefixedPubky = try await Self.establishSharedIdentitySession( + reference: option.reference, + secretKey: secretKey, + saveReference: { try SharedPubkyIdentityReferenceStore.save($0) }, + signIn: { try await PubkyService.signInSharedIdentity(secretKeyHex: $0) }, + currentPublicKey: { await PubkyService.currentPublicKey() }, + clearSession: { try await PubkyService.clearExternalSessionAccess() }, + deleteReference: { try SharedPubkyIdentityReferenceStore.delete() } + ) + + UserDefaults.standard.set(false, forKey: PrivatePaykitService.publishingEnabledKey) + Self.notifyAppStateBackupChanged() + publicKey = prefixedPubky + profile = option.profile + cacheProfileMetadata(option.profile) + authState = .completingAuthentication + await loadProfile() + return prefixedPubky + } catch { + authState = .idle + throw error + } + } + + nonisolated static func establishSharedIdentitySession( + reference: SharedPubkyIdentityRefV1, + secretKey: String, + saveReference: (SharedPubkyIdentityRefV1) throws -> Void, + signIn: (String) async throws -> String, + currentPublicKey: () async -> String?, + clearSession: () async throws -> Void, + deleteReference: () throws -> Void + ) async throws -> String { + do { + // The reference is the crash-safety marker. A launch after this write can revalidate + // the source and sign in again; no borrowed session can exist without it. + try saveReference(reference) + _ = try await signIn(secretKey) + + guard let signedInPublicKey = await currentPublicKey(), + SharedPubkyKeyFormat.normalizedBare(signedInPublicKey) == reference.pubky, + let prefixedPubky = SharedPubkyKeyFormat.prefixed(reference.pubky) + else { + throw SharedPubkyIdentityError.secretDoesNotMatchPublicKey + } + return prefixedPubky + } catch let adoptionError { + do { + // The durable source reference is also the cleanup-pending marker. Keep it until + // the local session is verifiably gone so launch/foreground validation retries. + try await clearSession() + try deleteReference() + } catch { + throw error + } + throw adoptionError + } + } + + /// Revalidates a borrowed identity without retaining its shared secret. + func validateSharedIdentitySourceIfNeeded() async { + await Self.withIdentityLifecycleLock { + await self.validateSharedIdentitySourceIfNeededLocked() + } + } + + private func validateSharedIdentitySourceIfNeededLocked() async { + let reference: SharedPubkyIdentityRefV1? + do { + reference = try SharedPubkyIdentityReferenceStore.load() + } catch { + await disconnectUnavailableSharedIdentityLocked() + return + } + + guard let reference else { + if let publicKey { + await reconcileBitkitOwnedIdentityIfNeededLocked(publicKey: publicKey) + } + return + } + + guard reference.sourceApp == .ring, Self.isRingAvailable() else { + await disconnectUnavailableSharedIdentityLocked() + return + } + + do { + _ = try await Task.detached { + try SharedPubkyIdentityVault.loadCredential(reference: reference) + }.value + } catch { + Logger.warn("Shared Pubky source became unavailable: \(error)", context: "PubkyProfileManager") + await disconnectUnavailableSharedIdentityLocked() + } + } + + private func sharedIdentitySourceIsUnavailable() -> Bool { + do { + guard let reference = try SharedPubkyIdentityReferenceStore.load() else { + return false + } + return reference.sourceApp != .ring || !Self.isRingAvailable() + } catch { + return true + } + } + + private func disconnectUnavailableSharedIdentityLocked() async { + sharedRingIdentities = [] + do { + try await Self.clearSharedIdentitySession() + clearAuthenticatedState() + sessionRestorationFailed = true + } catch { + // Keep the durable reference as a cleanup-pending marker and retry on the next + // launch/foreground validation. Authenticated operations still fail source checks. + Logger.error("Failed to clear unavailable shared Pubky session: \(error)", context: "PubkyProfileManager") + sessionRestorationFailed = true + } + } + + static func clearSharedIdentitySession( + clearSession: () async throws -> Void = { + try await PubkyService.clearExternalSessionAccess() + }, + deleteReference: () throws -> Void = { + try SharedPubkyIdentityReferenceStore.delete() + } + ) async throws { + // Session-first ordering prevents an orphaned session from ever outliving its source + // reference. If either step fails, the remaining reference drives a later retry. + try await clearSession() + try deleteReference() + } + + private func reconcileBitkitOwnedIdentityIfNeededLocked(publicKey: String) async { + guard (try? SharedPubkyIdentityReferenceStore.load()) == nil, + let secretKey = try? Keychain.loadString(key: .pubkySecretKey), + !secretKey.isEmpty, + Self.hasLocalSecretKey(for: publicKey) + else { + return + } + + do { + try await Task.detached { + try SharedPubkyIdentityVault.publishBitkitIdentity(pubky: publicKey, secretKey: secretKey) + }.value + } catch SharedPubkyIdentityError.missingEntitlement { + Logger.info("Deferring shared Pubky mirror until its entitlement is available", context: "PubkyProfileManager") + } catch { + Logger.warn("Failed to reconcile Bitkit-owned shared Pubky identity: \(error)", context: "PubkyProfileManager") + } + } + // MARK: - Auth Flow (Ring) func cancelAuthentication() async { @@ -498,11 +839,13 @@ class PubkyProfileManager: ObservableObject { /// Long-polls the relay, activates the SDK session, then loads the profile. @discardableResult func completeAuthentication() async throws -> String { - try await completeAuthentication( - completeAuth: { _ = try await PubkyService.completeAuth() }, - currentPublicKey: { await PubkyService.currentPublicKey() }, - clearSessionAccess: { await PubkyService.clearSessionAccess() } - ) + try await Self.withIdentityLifecycleLock { + try await self.completeAuthentication( + completeAuth: { _ = try await PubkyService.completeAuth() }, + currentPublicKey: { await PubkyService.currentPublicKey() }, + clearSessionAccess: { await PubkyService.clearSessionAccess() } + ) + } } @discardableResult @@ -660,11 +1003,14 @@ class PubkyProfileManager: ObservableObject { // MARK: - Sign Out static func clearLocalState() async { + // Callers replacing or deleting a Bitkit-owned private identity must first + // delete and verify its shared mirror. Ring-owned records are never deleted here. await PrivatePaykitService.shared.closeAndClear() await PrivatePaykitAddressReservationStore.shared.clearContactAssignments() await PubkyService.forceSignOut() try? Keychain.delete(key: .paykitSession) try? Keychain.delete(key: .pubkySecretKey) + try? SharedPubkyIdentityReferenceStore.delete() await PubkyImageCache.shared.clear() UserDefaults.standard.removeObject(forKey: cachedNameKey) UserDefaults.standard.removeObject(forKey: cachedImageUriKey) @@ -673,6 +1019,16 @@ class PubkyProfileManager: ObservableObject { notifyAppStateBackupChanged() } + private nonisolated static func deletePrivateIdentityCredentials() throws { + try Keychain.delete(key: .paykitSession) + try Keychain.delete(key: .pubkySecretKey) + guard try Keychain.load(key: .paykitSession) == nil, + try Keychain.load(key: .pubkySecretKey) == nil + else { + throw KeychainError.failedToDelete + } + } + private static func clearPublicPaykitSharingState() { UserDefaults.standard.set(false, forKey: PublicPaykitService.publishingEnabledKey) UserDefaults.standard.set(false, forKey: PrivatePaykitService.publishingEnabledKey) @@ -737,10 +1093,27 @@ class PubkyProfileManager: ObservableObject { } func signOut() async throws { - try await signOut(cleanPrivatePaykitEndpoints: true) + try await Self.withIdentityLifecycleLock { + try await self.signOutLocked(cleanPrivatePaykitEndpoints: true) + } } - private func signOut(cleanPrivatePaykitEndpoints: Bool) async throws { + private func signOutLocked(cleanPrivatePaykitEndpoints: Bool) async throws { + let sharedReference = try SharedPubkyIdentityReferenceStore.load() + let localSecret = try Keychain.loadString(key: .pubkySecretKey) + if sharedReference != nil, localSecret?.isEmpty == false { + throw SharedPubkyIdentityError.provenanceConflict + } + + let ownsIdentity = hasLocalSecretKeyForCurrentProfile + if localSecret?.isEmpty == false, !ownsIdentity { + throw SharedPubkyIdentityError.provenanceConflict + } + let hasSharedReference = sharedReference != nil + if ownsIdentity, let sourcePublicKey = publicKey { + try SharedPubkyIdentityVault.deleteBitkitIdentity(pubky: sourcePublicKey) + } + try await Task.detached { if cleanPrivatePaykitEndpoints { try await Self.removePrivatePaykitEndpoints(context: "PubkyProfileManager.signOut") @@ -751,14 +1124,53 @@ class PubkyProfileManager: ObservableObject { } catch { Logger.warn("Server sign out failed, forcing local sign out: \(error)", context: "PubkyProfileManager") } + + if hasSharedReference { + try await Self.clearSharedIdentitySession() + } else if ownsIdentity { + try Self.deletePrivateIdentityCredentials() + } await Self.clearLocalState() }.value + if ownsIdentity, let sourcePublicKey = publicKey { + try SharedPubkyIdentityVault.deleteBitkitIdentity(pubky: sourcePublicKey) + } clearAuthenticatedState() } func refreshSessionIfPossible(after error: Error) async -> Bool { - await Self.refreshSessionIfPossible( + await Self.withIdentityLifecycleLock { + await self.refreshSessionIfPossibleLocked(after: error) + } + } + + private func refreshSessionIfPossibleLocked(after error: Error) async -> Bool { + if let reference = try? SharedPubkyIdentityReferenceStore.load() { + guard Self.isSessionRefreshableError(error), + Self.isRingAvailable() + else { + return false + } + + do { + let secretKey = try SharedPubkyIdentityVault.loadCredential(reference: reference) + _ = try await PubkyService.signInSharedIdentity(secretKeyHex: secretKey) + guard let signedInPublicKey = await PubkyService.currentPublicKey(), + SharedPubkyKeyFormat.normalizedBare(signedInPublicKey) == reference.pubky + else { + throw SharedPubkyIdentityError.secretDoesNotMatchPublicKey + } + Logger.info("Refreshed Pubky session from source-owned identity", context: "PubkyProfileManager") + return true + } catch { + Logger.warn("Failed to refresh source-owned Pubky session: \(error)", context: "PubkyProfileManager") + await disconnectUnavailableSharedIdentityLocked() + return false + } + } + + return await Self.refreshSessionIfPossible( after: error, loadKeychainString: { try Keychain.loadString(key: $0) }, signInWithSecretKey: { try await PubkyService.signIn(secretKeyHex: $0) } @@ -800,6 +1212,13 @@ class PubkyProfileManager: ObservableObject { } private func activeSessionSecret() throws -> String { + if let reference = try SharedPubkyIdentityReferenceStore.load() { + guard reference.sourceApp == .ring, Self.isRingAvailable() else { + throw SharedPubkyIdentityError.sourceUnavailable + } + _ = try SharedPubkyIdentityVault.loadCredential(reference: reference) + } + guard let sessionSecret = try? Keychain.loadString(key: .paykitSession), !sessionSecret.isEmpty else { @@ -818,6 +1237,55 @@ class PubkyProfileManager: ObservableObject { Self.hasLocalSecretKey(for: publicKey) } + /// Returns the active identity key only at the point of use. Shared keys are never persisted privately. + func activeIdentitySecretKey() throws -> String { + guard let expectedPublicKey = publicKey else { + throw PubkyServiceError.sessionNotActive + } + + let reference = try SharedPubkyIdentityReferenceStore.load() + let localSecret = try Keychain.loadString(key: .pubkySecretKey) + return try Self.resolveActiveIdentitySecretKey( + expectedPublicKey: expectedPublicKey, + reference: reference, + localSecret: localSecret, + isSourceAvailable: Self.isRingAvailable(), + loadSharedCredential: { + try SharedPubkyIdentityVault.loadCredential(reference: $0) + } + ) + } + + nonisolated static func resolveActiveIdentitySecretKey( + expectedPublicKey: String, + reference: SharedPubkyIdentityRefV1?, + localSecret: String?, + isSourceAvailable: Bool, + loadSharedCredential: (SharedPubkyIdentityRefV1) throws -> String + ) throws -> String { + if let reference { + guard localSecret?.isEmpty != false else { + throw SharedPubkyIdentityError.provenanceConflict + } + guard reference.sourceApp == .ring, isSourceAvailable else { + throw SharedPubkyIdentityError.sourceUnavailable + } + guard SharedPubkyKeyFormat.normalizedBare(expectedPublicKey) == reference.pubky else { + throw SharedPubkyIdentityError.provenanceConflict + } + return try loadSharedCredential(reference) + } + + guard let localSecret, !localSecret.isEmpty, + let derivedPublicKey = try? publicKeyFromSecretKey(localSecret), + SharedPubkyKeyFormat.normalizedBare(derivedPublicKey) == + SharedPubkyKeyFormat.normalizedBare(expectedPublicKey) + else { + throw SharedPubkyIdentityError.provenanceConflict + } + return localSecret + } + nonisolated static func hasLocalSecretKey(for publicKey: String?) -> Bool { guard let publicKey, let secretKeyHex = try? Keychain.loadString(key: .pubkySecretKey), @@ -836,6 +1304,13 @@ class PubkyProfileManager: ObservableObject { try Keychain.loadString(key: $0) } ) throws -> PubkySessionBackupV1? { + if let sharedReference = try loadKeychainString(.sharedPubkyIdentityReference), + !sharedReference.isEmpty + { + // The source app remains authoritative; a borrowed identity is not a portable backup. + return nil + } + if let secretKeyHex = try loadKeychainString(.pubkySecretKey), !secretKeyHex.isEmpty { @@ -865,6 +1340,9 @@ class PubkyProfileManager: ObservableObject { deleteKeychainValue: (KeychainEntryType) throws -> Void = { try Keychain.delete(key: $0) }, + deleteBitkitSharedIdentities: () throws -> Void = { + try SharedPubkyIdentityVault.deleteAllBitkitIdentities() + }, clearSessionAccess: @escaping () async -> Void = { await PubkyService.clearSessionAccess() }, @@ -875,17 +1353,60 @@ class PubkyProfileManager: ObservableObject { try await PubkyService.importExternalSession(secret: $0) } ) async throws { + try await withIdentityLifecycleLock { + try await restoreSessionBackupStateLocked( + backup, + loadKeychainString: loadKeychainString, + persistKeychainString: persistKeychainString, + deleteKeychainValue: deleteKeychainValue, + deleteBitkitSharedIdentities: deleteBitkitSharedIdentities, + clearSessionAccess: clearSessionAccess, + signInWithSecretKey: signInWithSecretKey, + importExternalSession: importExternalSession + ) + } + } + + private nonisolated static func restoreSessionBackupStateLocked( + _ backup: PubkySessionBackupV1?, + loadKeychainString: (KeychainEntryType) throws -> String?, + persistKeychainString: (KeychainEntryType, String) throws -> Void, + deleteKeychainValue: (KeychainEntryType) throws -> Void, + deleteBitkitSharedIdentities: () throws -> Void, + clearSessionAccess: @escaping () async -> Void, + signInWithSecretKey: @escaping (String) async throws -> String, + importExternalSession: @escaping (String) async throws -> String + ) async throws { + let localSecretKey = try loadKeychainString(.pubkySecretKey) + let sharedReference = try loadKeychainString(.sharedPubkyIdentityReference) + if localSecretKey?.isEmpty == false, sharedReference?.isEmpty == false { + throw SharedPubkyIdentityError.provenanceConflict + } + + if localSecretKey?.isEmpty == false { + // Backup restore can replace an identity without going through AppReset. + // Verify every Bitkit-owned mirror is gone before clearing private state. + try deleteBitkitSharedIdentities() + } + await clearSessionAccess() + try deleteKeychainValue(.paykitSession) + try deleteKeychainValue(.pubkySecretKey) + try deleteKeychainValue(.sharedPubkyIdentityReference) + guard try loadKeychainString(.paykitSession) == nil, + try loadKeychainString(.pubkySecretKey) == nil, + try loadKeychainString(.sharedPubkyIdentityReference) == nil + else { + throw KeychainError.failedToDelete + } switch backup?.kind { case .none: // Backups without pubky state do not carry recoverable pubky credentials. - try? deleteKeychainValue(.paykitSession) - try? deleteKeychainValue(.pubkySecretKey) + break case .localSeed: let secretKeyHex = try deriveLocalSecretKeyFromWalletSeed(loadKeychainString: loadKeychainString) try persistKeychainString(.pubkySecretKey, secretKeyHex) - try? deleteKeychainValue(.paykitSession) _ = try await signInWithSecretKey(secretKeyHex) case .externalSession: guard let sessionSecret = backup?.sessionSecret, @@ -911,6 +1432,24 @@ class PubkyProfileManager: ObservableObject { try await PubkyService.initialize() let savedSecret = try Keychain.loadString(key: .paykitSession) + if let sharedReference = try SharedPubkyIdentityReferenceStore.load() { + do { + let sharedSecret = try SharedPubkyIdentityVault.loadCredential(reference: sharedReference) + return await resolveSharedSessionInitialization( + reference: sharedReference, + savedSessionSecret: savedSecret, + sharedSecretKey: sharedSecret, + importSession: { try await PubkyService.importExternalSession(secret: $0) }, + signInWithSharedSecret: { try await PubkyService.signInSharedIdentity(secretKeyHex: $0) }, + currentPublicKey: { await PubkyService.currentPublicKey() } + ) + } catch { + Logger.warn("Shared Pubky session source is unavailable: \(error)", context: "PubkyProfileManager") + try? await clearSharedIdentitySession() + return .restorationFailed + } + } + let secretKeyHex = try Keychain.loadString(key: .pubkySecretKey) return await resolveSessionInitialization( savedSessionSecret: savedSecret, @@ -923,6 +1462,43 @@ class PubkyProfileManager: ObservableObject { ) } + nonisolated static func resolveSharedSessionInitialization( + reference: SharedPubkyIdentityRefV1, + savedSessionSecret: String?, + sharedSecretKey: String, + importSession: (String) async throws -> String, + signInWithSharedSecret: (String) async throws -> String, + currentPublicKey: () async -> String? + ) async -> SessionInitializationResult { + if let savedSessionSecret, !savedSessionSecret.isEmpty { + do { + let restoredPublicKey = try await importSession(savedSessionSecret) + guard SharedPubkyKeyFormat.normalizedBare(restoredPublicKey) == reference.pubky, + let prefixedPubky = SharedPubkyKeyFormat.prefixed(reference.pubky) + else { + throw SharedPubkyIdentityError.secretDoesNotMatchPublicKey + } + return .restored(publicKey: prefixedPubky) + } catch { + Logger.warn("Shared Pubky session expired; signing in again from its source", context: "PubkyProfileManager") + } + } + + do { + _ = try await signInWithSharedSecret(sharedSecretKey) + guard let signedInPublicKey = await currentPublicKey(), + SharedPubkyKeyFormat.normalizedBare(signedInPublicKey) == reference.pubky, + let prefixedPubky = SharedPubkyKeyFormat.prefixed(reference.pubky) + else { + throw SharedPubkyIdentityError.secretDoesNotMatchPublicKey + } + return .restored(publicKey: prefixedPubky) + } catch { + Logger.warn("Could not restore source-owned Pubky session: \(error)", context: "PubkyProfileManager") + return .restorationFailed + } + } + private nonisolated static func notifyAppStateBackupChanged() { Task { @MainActor in SettingsViewModel.shared.notifyAppStateChanged() diff --git a/Bitkit/Models/SharedPubkyIdentity.swift b/Bitkit/Models/SharedPubkyIdentity.swift new file mode 100644 index 000000000..375602133 --- /dev/null +++ b/Bitkit/Models/SharedPubkyIdentity.swift @@ -0,0 +1,114 @@ +import Foundation + +enum SharedPubkyIdentitySource: String, Codable, Equatable { + case ring = "app.pubkyring" + case bitkit = "to.bitkit" +} + +enum SharedPubkyKeyFormat { + private static let prefix = "pubky" + private static let bareKeyLength = 52 + private static let allowedCharacters = Set("ybndrfg8ejkmcpqxot1uwisza345h769") + private static let secretKeyLength = 64 + private static let secretKeyCharacters = Set("0123456789abcdef") + + static func normalizedBare(_ value: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let bare = trimmed.hasPrefix(prefix) ? String(trimmed.dropFirst(prefix.count)) : trimmed + guard bare.count == bareKeyLength, + bare.allSatisfy({ allowedCharacters.contains($0) }) + else { + return nil + } + return bare + } + + static func isCanonicalSecretKey(_ value: String) -> Bool { + value.count == secretKeyLength && + value.allSatisfy { secretKeyCharacters.contains($0) } + } + + static func prefixed(_ bareValue: String) -> String? { + guard let bare = normalizedBare(bareValue), bare == bareValue else { + return nil + } + return "\(prefix)\(bare)" + } +} + +/// App-private pointer to an identity whose canonical secret remains owned by another app. +struct SharedPubkyIdentityRefV1: Codable, Equatable { + static let currentVersion = 1 + + let version: Int + let sourceApp: SharedPubkyIdentitySource + let pubky: String + + init(sourceApp: SharedPubkyIdentitySource, pubky: String) throws { + guard let normalizedPubky = SharedPubkyKeyFormat.normalizedBare(pubky) else { + throw SharedPubkyIdentityError.invalidPublicKey + } + + version = Self.currentVersion + self.sourceApp = sourceApp + self.pubky = normalizedPubky + } +} + +/// Payload stored in the shared Keychain access group by the app that owns the identity. +struct SharedPubkyIdentityRecordV1: Codable, Equatable { + static let currentVersion = 1 + + let version: Int + let sourceApp: SharedPubkyIdentitySource + let pubky: String + let secretKey: String + + init(sourceApp: SharedPubkyIdentitySource, pubky: String, secretKey: String) { + version = Self.currentVersion + self.sourceApp = sourceApp + self.pubky = pubky + self.secretKey = secretKey + } +} + +struct SharedPubkyIdentityOption: Identifiable { + let reference: SharedPubkyIdentityRefV1 + let profile: PubkyProfile + + var id: String { + reference.pubky + } +} + +enum SharedPubkyIdentityError: LocalizedError, Equatable { + case unavailable + case missingEntitlement + case invalidRecord + case invalidPublicKey + case secretDoesNotMatchPublicKey + case sourceUnavailable + case sourceIdentityMissing + case provenanceConflict + + var errorDescription: String? { + switch self { + case .unavailable: + return "Shared Pubky identities are unavailable" + case .missingEntitlement: + return "Shared Pubky Keychain access is not configured" + case .invalidRecord: + return "The shared Pubky identity is invalid" + case .invalidPublicKey: + return "The shared Pubky public key is invalid" + case .secretDoesNotMatchPublicKey: + return "The shared Pubky secret does not match its public key" + case .sourceUnavailable: + return "Pubky Ring is unavailable" + case .sourceIdentityMissing: + return "The selected Pubky Ring identity is no longer available" + case .provenanceConflict: + return "Conflicting Pubky identity sources require recovery" + } + } +} diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index f43256552..7b5dfcd51 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1118,8 +1118,9 @@ "profile__sign_out_description" = "This will disconnect your Pubky profile from Bitkit. You can reconnect at any time."; "profile__ring_waiting" = "Waiting for authorization from Pubky Ring…"; "profile__ring_loading" = "Loading your profile…"; -"profile__choice_title" = "Join the\npubky web"; -"profile__choice_description" = "Create a new pubky and profile in Bitkit, or import an existing profile with Pubky Ring."; +"profile__choice_title" = "ENTER THE\nFREEDOM WEB"; +"profile__choice_description" = "Create a new pubky and profile in Bitkit, or use an existing pubky from Pubky Ring."; +"profile__choice_new_pubky" = "NEW PUBKY"; "profile__choice_create" = "Create profile with Bitkit"; "profile__choice_import" = "Import with Pubky Ring"; "profile__deriving_keys" = "Deriving your keys…"; diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 6be3f56ca..e8717c6d8 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -126,6 +126,15 @@ enum PubkyService { return result.sessionAccess.exportSessionSecret() } + /// Signs in with a source-owned shared identity without persisting its secret in Bitkit's private Keychain. + static func signInSharedIdentity(secretKeyHex: String) async throws -> String { + let result = try await PaykitSdkService.shared.signIn( + secretKeyHex: secretKeyHex, + shouldStoreLocalSecret: false + ) + return result.sessionAccess.exportSessionSecret() + } + // MARK: - File Fetching /// Fetch raw bytes from a `pubky://` URI via PKDNS resolution. @@ -186,6 +195,10 @@ enum PubkyService { static func clearSessionAccess() async { await PaykitSdkService.shared.clearSessionAccess() } + + static func clearExternalSessionAccess() async throws { + try await PaykitSdkService.shared.clearExternalSessionAccess() + } } // MARK: - Paykit SDK Runtime @@ -262,14 +275,21 @@ actor PaykitSdkService { } } - func signIn(secretKeyHex: String) async throws -> PubkySessionBootstrapResult { + func signIn( + secretKeyHex: String, + shouldStoreLocalSecret: Bool = true + ) async throws -> PubkySessionBootstrapResult { try await operationLock.withLock { let previousPublicKey = await currentSdkStatePublicKey() let result = try await bootstrap().signIn( localSecretKey: Self.localSecretKey(fromHex: secretKeyHex), requiredCapabilities: Self.requiredCapabilities() ) - try await activateBootstrapResult(result, previousPublicKey: previousPublicKey, shouldStoreLocalSecret: true) + try await activateBootstrapResult( + result, + previousPublicKey: previousPublicKey, + shouldStoreLocalSecret: shouldStoreLocalSecret + ) markWalletBackupDataChanged() return result } @@ -614,6 +634,20 @@ actor PaykitSdkService { } } + func clearExternalSessionAccess() async throws { + try await operationLock.withLock { + try Keychain.delete(key: .paykitSession) + guard try Keychain.load(key: .paykitSession) == nil else { + throw KeychainError.failedToDelete + } + sessionProvider.clearLiveSessionAccess() + activeAuthRequest = nil + activeAuthRequestID = nil + resetRuntime() + markWalletBackupDataChanged() + } + } + func clearState() async { await operationLock.withLock { clearStateLocked() @@ -700,22 +734,48 @@ actor PaykitSdkService { } private func persistSessionAccess(_ access: PubkySessionAccess, shouldStoreLocalSecret: Bool) throws { + let localSecret = access.exportLocalSecretKey() + if !shouldStoreLocalSecret, + let existingSecret = try Keychain.loadString(key: .pubkySecretKey), + !existingSecret.isEmpty + { + // An external or borrowed session must never silently replace a + // Bitkit-owned canonical identity. + throw PubkyServiceError.authFailed("A local Pubky identity already exists") + } + let localSecretHex = try Self.localSecretKeyHexForPersistence( + localSecret, + shouldStoreLocalSecret: shouldStoreLocalSecret + ) + guard let sessionData = access.exportSessionSecret().data(using: .utf8) else { throw KeychainError.failedToSave } try Keychain.upsert(key: .paykitSession, data: sessionData) - guard shouldStoreLocalSecret, let localSecret = access.exportLocalSecretKey() else { - try? Keychain.delete(key: .pubkySecretKey) + guard let localSecretHex else { return } - guard let secretData = Self.secretKeyHex(from: localSecret).data(using: .utf8) else { + guard let secretData = localSecretHex.data(using: .utf8) else { throw KeychainError.failedToSave } try Keychain.upsert(key: .pubkySecretKey, data: secretData) } + static func localSecretKeyHexForPersistence( + _ localSecret: PubkyLocalSecretKey?, + shouldStoreLocalSecret: Bool + ) throws -> String? { + guard shouldStoreLocalSecret else { + return nil + } + guard let localSecret else { + throw PubkyServiceError.authFailed("Local Pubky session did not include its secret key") + } + return secretKeyHex(from: localSecret) + } + private func activateBootstrapResult( _ result: PubkySessionBootstrapResult, previousPublicKey: String?, diff --git a/Bitkit/Services/SharedPubkyIdentityVault.swift b/Bitkit/Services/SharedPubkyIdentityVault.swift new file mode 100644 index 000000000..9ba1df90b --- /dev/null +++ b/Bitkit/Services/SharedPubkyIdentityVault.swift @@ -0,0 +1,306 @@ +import Foundation +import Security + +/// A source-owned interoperability mirror. The source app's private Keychain remains canonical. +enum SharedPubkyIdentityVault { + static let service = "pubky.identity-sharing.v1" + static let sharedAccessGroupInfoKey = "SharedPubkyKeychainAccessGroup" + + static func account(source: SharedPubkyIdentitySource, pubky: String) -> String { + "\(source.rawValue):\(pubky)" + } + + static func list(source: SharedPubkyIdentitySource) throws -> [SharedPubkyIdentityRefV1] { + try references(accounts: allAccounts(), source: source) + } + + static func allAccounts() throws -> [String] { + let query: [String: Any] = try [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccessGroup as String: sharedAccessGroup(), + kSecAttrSynchronizable as String: false, + kSecReturnAttributes as String: true, + kSecMatchLimit as String: kSecMatchLimitAll, + ] + + var rawResult: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &rawResult) + if status == errSecItemNotFound { + return [] + } + try check(status) + + let attributes: [[String: Any]] + if let all = rawResult as? [[String: Any]] { + attributes = all + } else if let one = rawResult as? [String: Any] { + attributes = [one] + } else { + throw SharedPubkyIdentityError.invalidRecord + } + + return attributes.compactMap { $0[kSecAttrAccount as String] as? String } + } + + /// Loads secret data for one explicitly selected identity. Discovery never calls this. + static func loadCredential( + reference: SharedPubkyIdentityRefV1, + derivePublicKey: (String) throws -> String = { + try PubkyProfileManager.publicKeyFromSecretKey($0) + } + ) throws -> String { + guard reference.version == SharedPubkyIdentityRefV1.currentVersion else { + throw SharedPubkyIdentityError.invalidRecord + } + + let query: [String: Any] = try [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account(source: reference.sourceApp, pubky: reference.pubky), + kSecAttrAccessGroup as String: sharedAccessGroup(), + kSecAttrSynchronizable as String: false, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + + var rawResult: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &rawResult) + if status == errSecItemNotFound { + throw SharedPubkyIdentityError.sourceIdentityMissing + } + try check(status) + + guard let data = rawResult as? Data, + let record = try? JSONDecoder().decode(SharedPubkyIdentityRecordV1.self, from: data) + else { + throw SharedPubkyIdentityError.invalidRecord + } + + try validate(record: record, expected: reference, derivePublicKey: derivePublicKey) + return record.secretKey + } + + static func publishBitkitIdentity(pubky: String, secretKey: String) throws { + let reference = try SharedPubkyIdentityRefV1(sourceApp: .bitkit, pubky: pubky) + let record = SharedPubkyIdentityRecordV1( + sourceApp: .bitkit, + pubky: reference.pubky, + secretKey: secretKey + ) + try validate( + record: record, + expected: reference, + derivePublicKey: { try PubkyProfileManager.publicKeyFromSecretKey($0) } + ) + let payload = try JSONEncoder().encode(record) + let accessGroup = try sharedAccessGroup() + let itemAccount = account(source: .bitkit, pubky: reference.pubky) + + let searchQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: itemAccount, + kSecAttrAccessGroup as String: accessGroup, + kSecAttrSynchronizable as String: false, + ] + let updateStatus = SecItemUpdate( + searchQuery as CFDictionary, + [ + kSecValueData as String: payload, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + ] as CFDictionary + ) + + if updateStatus == errSecItemNotFound { + var addQuery = searchQuery + addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly + addQuery[kSecValueData as String] = payload + try check(SecItemAdd(addQuery as CFDictionary, nil)) + } else { + try check(updateStatus) + } + + let storedSecret = try loadCredential(reference: reference) + guard storedSecret == secretKey else { + throw SharedPubkyIdentityError.invalidRecord + } + + // Bitkit owns exactly one local identity. Only prune stale Bitkit-owned + // accounts after the current mirror has been written and verified. + try pruneStaleBitkitIdentities( + keeping: itemAccount, + listAccounts: { try allAccounts() }, + deleteAccount: { try deleteOwnedAccount($0) } + ) + } + + static func deleteBitkitIdentity(pubky: String) throws { + guard let normalizedPubky = SharedPubkyKeyFormat.normalizedBare(pubky) else { + throw SharedPubkyIdentityError.invalidPublicKey + } + + let itemAccount = account(source: .bitkit, pubky: normalizedPubky) + try deleteOwnedAccount(itemAccount) + + let remainingAccounts = try allAccounts() + guard !remainingAccounts.contains(itemAccount) else { + throw SharedPubkyIdentityError.invalidRecord + } + } + + static func deleteAllBitkitIdentities() throws { + let accounts = try ownedAccounts(accounts: allAccounts(), source: .bitkit) + for itemAccount in accounts { + try deleteOwnedAccount(itemAccount) + } + + guard try ownedAccounts(accounts: allAccounts(), source: .bitkit).isEmpty else { + throw SharedPubkyIdentityError.invalidRecord + } + } + + static func pruneStaleBitkitIdentities( + keeping currentAccount: String, + listAccounts: () throws -> [String], + deleteAccount: (String) throws -> Void + ) throws { + let ownedBeforePruning = try ownedAccounts(accounts: listAccounts(), source: .bitkit) + guard ownedBeforePruning.contains(currentAccount) else { + throw SharedPubkyIdentityError.invalidRecord + } + + for staleAccount in ownedBeforePruning where staleAccount != currentAccount { + try deleteAccount(staleAccount) + } + + guard try ownedAccounts(accounts: listAccounts(), source: .bitkit) == [currentAccount] else { + throw SharedPubkyIdentityError.invalidRecord + } + } + + static func ownedAccounts( + accounts: [String], + source: SharedPubkyIdentitySource + ) -> [String] { + let prefix = "\(source.rawValue):" + return Array(Set(accounts.filter { $0.hasPrefix(prefix) })).sorted() + } + + static func references( + accounts: [String], + source: SharedPubkyIdentitySource + ) -> [SharedPubkyIdentityRefV1] { + let prefix = "\(source.rawValue):" + var seen = Set() + + return ownedAccounts(accounts: accounts, source: source).compactMap { value -> SharedPubkyIdentityRefV1? in + let wirePubky = String(value.dropFirst(prefix.count)) + guard SharedPubkyKeyFormat.normalizedBare(wirePubky) == wirePubky, + let reference = try? SharedPubkyIdentityRefV1( + sourceApp: source, + pubky: wirePubky + ), + seen.insert(reference.pubky).inserted + else { + return nil + } + return reference + } + .sorted { $0.pubky < $1.pubky } + } + + static func validate( + record: SharedPubkyIdentityRecordV1, + expected: SharedPubkyIdentityRefV1, + derivePublicKey: (String) throws -> String + ) throws { + guard record.version == SharedPubkyIdentityRecordV1.currentVersion, + expected.version == SharedPubkyIdentityRefV1.currentVersion, + record.sourceApp == expected.sourceApp, + record.pubky == expected.pubky, + SharedPubkyKeyFormat.isCanonicalSecretKey(record.secretKey) + else { + throw SharedPubkyIdentityError.invalidRecord + } + + let derivedPubky: String + do { + derivedPubky = try derivePublicKey(record.secretKey) + } catch { + throw SharedPubkyIdentityError.invalidRecord + } + guard SharedPubkyKeyFormat.normalizedBare(derivedPubky) == expected.pubky else { + throw SharedPubkyIdentityError.secretDoesNotMatchPublicKey + } + } + + static func sharedAccessGroup(bundle: Bundle = .main) throws -> String { + guard let value = bundle.object(forInfoDictionaryKey: sharedAccessGroupInfoKey) as? String, + !value.isEmpty, + !value.contains("$(") + else { + throw SharedPubkyIdentityError.unavailable + } + return value + } + + private static func check(_ status: OSStatus) throws { + guard status != noErr else { return } + if status == errSecMissingEntitlement { + throw SharedPubkyIdentityError.missingEntitlement + } + + Logger.warn("Shared Pubky Keychain operation failed with status \(status)", context: "SharedPubkyIdentityVault") + throw SharedPubkyIdentityError.unavailable + } + + private static func deleteOwnedAccount(_ itemAccount: String) throws { + guard ownedAccounts(accounts: [itemAccount], source: .bitkit) == [itemAccount] else { + throw SharedPubkyIdentityError.invalidRecord + } + + let query: [String: Any] = try [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: itemAccount, + kSecAttrAccessGroup as String: sharedAccessGroup(), + kSecAttrSynchronizable as String: false, + ] + let status = SecItemDelete(query as CFDictionary) + guard status == noErr || status == errSecItemNotFound else { + try check(status) + return + } + } +} + +enum SharedPubkyIdentityReferenceStore { + static func load() throws -> SharedPubkyIdentityRefV1? { + guard let data = try Keychain.load(key: .sharedPubkyIdentityReference) else { + return nil + } + + guard let reference = try? JSONDecoder().decode(SharedPubkyIdentityRefV1.self, from: data), + reference.version == SharedPubkyIdentityRefV1.currentVersion, + SharedPubkyKeyFormat.normalizedBare(reference.pubky) == reference.pubky + else { + throw SharedPubkyIdentityError.invalidRecord + } + return reference + } + + static func save(_ reference: SharedPubkyIdentityRefV1) throws { + try Keychain.upsert( + key: .sharedPubkyIdentityReference, + data: JSONEncoder().encode(reference) + ) + } + + static func delete() throws { + try Keychain.delete(key: .sharedPubkyIdentityReference) + guard try Keychain.load(key: .sharedPubkyIdentityReference) == nil else { + throw KeychainError.failedToDelete + } + } +} diff --git a/Bitkit/Styles/Colors.swift b/Bitkit/Styles/Colors.swift index adb58e7d4..23fe8d430 100644 --- a/Bitkit/Styles/Colors.swift +++ b/Bitkit/Styles/Colors.swift @@ -9,7 +9,7 @@ extension Color { static let purpleAccent = Color(hex: 0xB95CE8) static let redAccent = Color(hex: 0xE95164) static let yellowAccent = Color(hex: 0xFFD200) - static let pubkyGreen = Color(hex: 0xBEFF00) + static let pubkyGreen = Color(hex: 0xC8FF00) static let bitcoin = Color(hex: 0xF7931A) // MARK: - Base diff --git a/Bitkit/Styles/TextStyle.swift b/Bitkit/Styles/TextStyle.swift index 403564a8f..33efa68de 100644 --- a/Bitkit/Styles/TextStyle.swift +++ b/Bitkit/Styles/TextStyle.swift @@ -166,19 +166,22 @@ struct BodyMText: View { var accentAction: (() -> Void)? private let fontSize: CGFloat = 17 + private let kerningValue: CGFloat init( _ text: String, textColor: Color = .textSecondary, accentColor: Color = .white, accentFont: ((CGFloat) -> Font)? = nil, - accentAction: (() -> Void)? = nil + accentAction: (() -> Void)? = nil, + kerning: CGFloat = 0.4 ) { self.text = text self.textColor = textColor self.accentColor = accentColor self.accentFont = accentFont self.accentAction = accentAction + kerningValue = kerning } var body: some View { @@ -190,7 +193,7 @@ struct BodyMText: View { accentFont: accentFont?(fontSize), accentAction: accentAction ) - .kerning(0.4) + .kerning(kerningValue) } } diff --git a/Bitkit/Utilities/AppReset.swift b/Bitkit/Utilities/AppReset.swift index ddc12ddfe..e0abb7b87 100644 --- a/Bitkit/Utilities/AppReset.swift +++ b/Bitkit/Utilities/AppReset.swift @@ -9,6 +9,26 @@ enum AppReset { session: SessionManager, toastType: Toast.ToastType = .success ) async throws { + try await PubkyProfileManager.withIdentityLifecycleLock { + try await wipeLocked( + app: app, + wallet: wallet, + session: session, + toastType: toastType + ) + } + } + + @MainActor + private static func wipeLocked( + app: AppViewModel, + wallet: WalletViewModel, + session: SessionManager, + toastType: Toast.ToastType + ) async throws { + // Shared mirrors must be gone before any server or canonical private source is cleared. + try SharedPubkyIdentityVault.deleteAllBitkitIdentities() + await PubkyProfileManager.removePublicPaykitEndpointsBestEffort(context: "AppReset.wipe") await PubkyProfileManager.removePrivatePaykitEndpointsBestEffort(context: "AppReset.wipe") @@ -69,6 +89,9 @@ enum AppReset { title: t("security__wiped_title"), description: t("security__wiped_message") ) + + // Re-verify while the identity lifecycle gate still excludes reconciliation. + try SharedPubkyIdentityVault.deleteAllBitkitIdentities() } private static func wipeLogs() throws { diff --git a/Bitkit/Utilities/Keychain.swift b/Bitkit/Utilities/Keychain.swift index a05778260..85777958e 100644 --- a/Bitkit/Utilities/Keychain.swift +++ b/Bitkit/Utilities/Keychain.swift @@ -9,6 +9,7 @@ enum KeychainEntryType { case paykitSession case paykitSdkState case pubkySecretKey + case sharedPubkyIdentityReference var storageKey: String { switch self { @@ -19,6 +20,7 @@ enum KeychainEntryType { case .paykitSession: "paykit_session" case .paykitSdkState: "paykit_sdk_state" case .pubkySecretKey: "pubky_secret_key" + case .sharedPubkyIdentityReference: "shared_pubky_identity_reference_v1" } } } @@ -215,6 +217,7 @@ class Keychain { class func getAllKeyChainStorageKeys() -> [String] { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, + kSecAttrAccessGroup as String: Env.keychainGroup, kSecReturnData as String: kCFBooleanTrue!, kSecReturnAttributes as String: kCFBooleanTrue!, kSecReturnRef as String: kCFBooleanTrue!, diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 5598341ce..1db8b103c 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -676,15 +676,16 @@ extension AppViewModel { return } - // State 2: Ring-authenticated (has session but no local secret key) - guard let secretKey = try? Keychain.loadString(key: .pubkySecretKey), - !secretKey.isEmpty - else { + let hasLocalSecret = (try? Keychain.loadString(key: .pubkySecretKey))?.isEmpty == false + let hasSharedSource = (try? SharedPubkyIdentityReferenceStore.load()) != nil + && PubkyProfileManager.isRingAvailable() + + // A source-owned identity can approve after retrieving its key just in time. + guard hasLocalSecret || hasSharedSource else { toast(type: .info, title: t("pubky_auth__use_ring"), description: t("pubky_auth__use_ring_desc")) return } - // State 3: Bitkit-generated identity — can approve do { let request = try PubkyAuthRequest.parse(url: authUrl) sheetViewModel.showSheet(.pubkyAuthApproval, data: PubkyAuthApprovalConfig(authUrl: authUrl, request: request)) diff --git a/Bitkit/Views/Profile/PubkyChoiceView.swift b/Bitkit/Views/Profile/PubkyChoiceView.swift index 123721454..c23a210b6 100644 --- a/Bitkit/Views/Profile/PubkyChoiceView.swift +++ b/Bitkit/Views/Profile/PubkyChoiceView.swift @@ -1,37 +1,36 @@ import SwiftUI struct PubkyChoiceView: View { - @EnvironmentObject var app: AppViewModel - @EnvironmentObject var navigation: NavigationViewModel - @EnvironmentObject var pubkyProfile: PubkyProfileManager - @EnvironmentObject var contactsManager: ContactsManager - @Environment(\.scenePhase) var scenePhase + @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var navigation: NavigationViewModel + @EnvironmentObject private var pubkyProfile: PubkyProfileManager + @EnvironmentObject private var contactsManager: ContactsManager + @Environment(\.scenePhase) private var scenePhase - @State private var isAuthenticating = false - @State private var isWaitingForRing = false - @State private var isLoadingAfterAuth = false - @State private var showRingNotInstalledDialog = false - - private let pubkyRingAppStoreUrl = "https://apps.apple.com/app/pubky-ring/id6739356756" + @State private var selectedPubky: String? var body: some View { ZStack { backgroundIllustrations VStack(spacing: 0) { - NavigationBar(title: t("profile__nav_title")) + NavigationBar(title: "") + .overlay { + TitleText(t("profile__nav_title")) + .allowsHitTesting(false) + } .padding(.horizontal, 16) - VStack(alignment: .leading, spacing: 0) { - titleSection - .padding(.top, 24) - .padding(.bottom, 24) + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + titleSection + .padding(.top, 20) + .padding(.bottom, 33) - optionCards + optionCards + } + .padding(.horizontal, 16) } - .padding(.horizontal, 16) - - Spacer() } } .clipped() @@ -39,38 +38,19 @@ struct PubkyChoiceView: View { .bottomSafeAreaPadding() .background(Color.customBlack) .navigationBarHidden(true) - .task(id: isWaitingForRing) { - guard isWaitingForRing else { return } - await waitForApproval() + .task { + await pubkyProfile.refreshSharedRingIdentities() } .onChange(of: scenePhase) { _, newPhase in - if newPhase == .active, isWaitingForRing { - // Ring returned to app — approval task handles completion + guard newPhase == .active else { return } + Task { + await pubkyProfile.refreshSharedRingIdentities() } } - .onChange(of: pubkyProfile.authState) { _, authState in - authState.resetRingAuthViewStateIfNeeded( - isAuthenticating: $isAuthenticating, - isWaitingForRing: $isWaitingForRing, - isLoadingAfterAuth: $isLoadingAfterAuth - ) - } - .alert(t("profile__ring_not_installed_title"), isPresented: $showRingNotInstalledDialog) { - Button(t("profile__ring_download")) { - if let url = URL(string: pubkyRingAppStoreUrl) { - Task { await UIApplication.shared.open(url) } - } - } - Button(t("common__dialog_cancel"), role: .cancel) {} - } message: { - Text(t("profile__ring_not_installed_description")) - } } - // MARK: - Title Section - private var titleSection: some View { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 13.5) { DisplayText( t("profile__choice_title"), accentColor: .pubkyGreen @@ -78,77 +58,40 @@ struct PubkyChoiceView: View { .frame(maxWidth: .infinity, alignment: .leading) .fixedSize(horizontal: false, vertical: true) - BodyMText(isLoadingAfterAuth - ? t("profile__ring_loading") - : isWaitingForRing ? t("profile__ring_waiting") : t("profile__choice_description")) + BodyMText(t("profile__choice_description"), kerning: 0) .frame(maxWidth: .infinity, alignment: .leading) .fixedSize(horizontal: false, vertical: true) } } - // MARK: - Option Cards - private var optionCards: some View { VStack(spacing: 8) { - choiceCard( - icon: "user-plus", - title: t("profile__choice_create"), - accessibilityId: "PubkyChoiceCreate" - ) { - navigation.navigate(.createProfile) + createCard + + ForEach(pubkyProfile.sharedRingIdentities) { identity in + sharedIdentityCard(identity) } - .disabled(isAuthenticating || isWaitingForRing || isLoadingAfterAuth) - if isWaitingForRing || isLoadingAfterAuth { - ringWaitingCard - } else { - choiceCard( - systemIcon: "key.fill", - title: t("profile__choice_import"), - isLoading: isAuthenticating, - accessibilityId: "PubkyChoiceImport" - ) { - await startRingAuth() - } - .disabled(isAuthenticating) + if pubkyProfile.isLoadingSharedRingIdentities, + pubkyProfile.sharedRingIdentities.isEmpty + { + discoveryLoadingCard } } } - private func choiceCard( - icon: String? = nil, - systemIcon: String? = nil, - title: String, - isLoading: Bool = false, - accessibilityId: String, - action: @escaping () async -> Void - ) -> some View { + private var createCard: some View { Button { - Task { await action() } + navigation.navigate(.createProfile) } label: { HStack(spacing: 16) { - ZStack { - Circle() - .fill(Color.black) - .frame(width: 40, height: 40) + cardIcon - if isLoading { - ActivityIndicator(size: 20) - } else if let icon { - Image(icon) - .resizable() - .scaledToFit() - .foregroundColor(.pubkyGreen) - .frame(width: 20, height: 20) - } else if let systemIcon { - Image(systemName: systemIcon) - .font(.system(size: 16, weight: .semibold)) - .foregroundColor(.pubkyGreen) - } + VStack(alignment: .leading, spacing: 2) { + CaptionMText(t("profile__choice_new_pubky"), textColor: .white64) + BodyMSBText(t("profile__choice_create"), textColor: .white) } - BodyMSBText(title, textColor: .white) - Spacer() } .padding(.horizontal, 16) @@ -156,96 +99,128 @@ struct PubkyChoiceView: View { .background(Color.gray6) .cornerRadius(16) } - .accessibilityIdentifier(accessibilityId) + .buttonStyle(.plain) + .disabled(selectedPubky != nil) + .accessibilityIdentifier("PubkyChoiceCreate") } - // MARK: - Ring Auth + private func sharedIdentityCard(_ identity: SharedPubkyIdentityOption) -> some View { + Button { + Task { + await useSharedIdentity(identity) + } + } label: { + HStack(spacing: 16) { + cardKeyIcon + + VStack(alignment: .leading, spacing: 2) { + CaptionMText( + PubkyPublicKeyFormat.displayTruncated(identity.reference.pubky), + textColor: .white64 + ) + BodyMSBText(identity.profile.name, textColor: .white) + .lineLimit(1) + } - private func startRingAuth() async { - isAuthenticating = true + Spacer(minLength: 8) - do { - try await pubkyProfile.startAuthentication() - isAuthenticating = false - isWaitingForRing = true - } catch PubkyServiceError.ringNotInstalled { - isAuthenticating = false - showRingNotInstalledDialog = true - } catch { - isAuthenticating = false - app.toast(type: .error, title: t("profile__auth_error_title"), description: error.localizedDescription) + if selectedPubky == identity.reference.pubky { + ActivityIndicator(size: 24) + .frame(width: 40, height: 40) + } else { + PubkyContactAvatar( + name: identity.profile.name, + imageUrl: identity.profile.imageUrl, + size: 32 + ) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 16) + .background(Color.gray6) + .cornerRadius(16) } + .buttonStyle(.plain) + .disabled(selectedPubky != nil) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("PubkyChoiceShared_\(identity.reference.pubky)") } - private func waitForApproval() async { - do { - let publicKey = try await pubkyProfile.completeAuthentication() - isLoadingAfterAuth = true - await navigateAfterAuth(publicKey: publicKey) - } catch is CancellationError { - isWaitingForRing = false - await pubkyProfile.cancelAuthentication() - } catch { - isWaitingForRing = false - app.toast(type: .error, title: t("profile__auth_error_title"), description: error.localizedDescription) + private var discoveryLoadingCard: some View { + HStack(spacing: 16) { + cardKeyIcon + ActivityIndicator(size: 20) + BodyMSBText(t("profile__ring_loading"), textColor: .white64) + Spacer() } + .padding(.horizontal, 16) + .padding(.vertical, 16) + .background(Color.gray6) + .cornerRadius(16) + .accessibilityIdentifier("PubkyChoiceSharedLoading") } - private func navigateAfterAuth(publicKey: String) async { - let destination = await contactsManager.destinationAfterAuthentication( - profile: pubkyProfile.profile, - publicKey: publicKey - ) - navigation.path = [destination] - pubkyProfile.finalizeAuthentication() - } - - // MARK: - Ring Waiting Card + private var cardIcon: some View { + ZStack { + Circle() + .fill(Color.black) + .frame(width: 40, height: 40) - private var ringWaitingCard: some View { - VStack(spacing: 12) { - HStack(spacing: 16) { - ZStack { - Circle() - .fill(Color.black) - .frame(width: 40, height: 40) + Image("user-plus") + .resizable() + .scaledToFit() + .foregroundColor(.pubkyGreen) + .frame(width: 20, height: 20) + } + } - ActivityIndicator(size: 20) - } + private var cardKeyIcon: some View { + ZStack { + Circle() + .fill(Color.black) + .frame(width: 40, height: 40) - BodyMSBText(t(isLoadingAfterAuth ? "profile__ring_loading" : "profile__ring_waiting"), textColor: .white) + Image("key") + .resizable() + .scaledToFit() + .foregroundColor(.pubkyGreen) + .frame(width: 20, height: 20) + } + } - Spacer() - } + private func useSharedIdentity(_ identity: SharedPubkyIdentityOption) async { + guard selectedPubky == nil else { return } + selectedPubky = identity.reference.pubky + defer { selectedPubky = nil } - if !isLoadingAfterAuth { - Button { - isWaitingForRing = false - Task { await pubkyProfile.cancelAuthentication() } - } label: { - BodySSBText(t("common__cancel"), textColor: .white64) - } - .frame(maxWidth: .infinity, alignment: .trailing) - .accessibilityIdentifier("PubkyChoiceCancelRing") - } + do { + let publicKey = try await pubkyProfile.useSharedRingIdentity(identity) + let destination = await contactsManager.destinationAfterAuthentication( + profile: pubkyProfile.profile, + publicKey: publicKey + ) + navigation.path = [destination] + pubkyProfile.finalizeAuthentication() + } catch { + Logger.warn("Failed to use shared Pubky Ring identity: \(error)", context: "PubkyChoiceView") + app.toast( + type: .error, + title: t("profile__auth_error_title"), + description: error.localizedDescription + ) + await pubkyProfile.refreshSharedRingIdentities() } - .padding(.horizontal, 16) - .padding(.vertical, 16) - .background(Color.gray6) - .cornerRadius(16) } - // MARK: - Background Illustrations - private var backgroundIllustrations: some View { GeometryReader { geo in Image("tag-pubky") .resizable() .scaledToFit() - .frame(width: geo.size.width * 0.83) + .frame(width: geo.size.width * 0.64) .position( - x: geo.size.width * 0.321, - y: geo.size.height * 0.376 + 200 + x: geo.size.width * 0.187, + y: geo.size.height * 0.376 + 364 ) Image("keyring") @@ -254,8 +229,8 @@ struct PubkyChoiceView: View { .frame(width: geo.size.width * 0.83) .opacity(0.9) .position( - x: geo.size.width * 0.841, - y: geo.size.height * 0.305 + 200 + x: geo.size.width * 0.751, + y: geo.size.height * 0.305 + 370 ) } .ignoresSafeArea() diff --git a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift index 4ef7939a7..936da7848 100644 --- a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift +++ b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift @@ -299,13 +299,7 @@ struct PubkyAuthApprovalSheet: View { state = .authorizing do { - guard let secretKey = try Keychain.loadString(key: .pubkySecretKey), - !secretKey.isEmpty - else { - app.toast(type: .error, title: t("pubky_auth__no_identity")) - state = .authorize - return - } + let secretKey = try pubkyProfile.activeIdentitySecretKey() try await PubkyService.approveAuth( authUrl: config.authUrl, diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index 45b39210c..2db19cf2e 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -436,6 +436,7 @@ final class PubkyProfileManagerTests: XCTestCase { deleteKeychainValue: { key in store.removeValue(forKey: key.storageKey) }, + deleteBitkitSharedIdentities: {}, clearSessionAccess: { didClearSessionAccess = true }, @@ -461,6 +462,7 @@ final class PubkyProfileManagerTests: XCTestCase { paykitSession: "stale-session", pubkySecretKey: "local-secret" ) + var events: [String] = [] try await PubkyProfileManager.restoreSessionBackupState( nil, @@ -472,8 +474,16 @@ final class PubkyProfileManagerTests: XCTestCase { }, deleteKeychainValue: { key in store.removeValue(forKey: key.storageKey) + if case .pubkySecretKey = key { + events.append("private") + } + }, + deleteBitkitSharedIdentities: { + events.append("shared") + }, + clearSessionAccess: { + events.append("session") }, - clearSessionAccess: {}, signInWithSecretKey: { _ in XCTFail("Missing pubky state should not sign in") return "unused-session" @@ -486,6 +496,43 @@ final class PubkyProfileManagerTests: XCTestCase { XCTAssertNil(store[KeychainEntryType.paykitSession.storageKey]) XCTAssertNil(store[KeychainEntryType.pubkySecretKey.storageKey]) + XCTAssertEqual(events, ["shared", "session", "private"]) + } + + func testRestorePreservesPrivateIdentityWhenSharedMirrorDeletionFails() async { + var store = makeKeychainStore( + paykitSession: "stale-session", + pubkySecretKey: "local-secret" + ) + var didClearSessionAccess = false + + do { + try await PubkyProfileManager.restoreSessionBackupState( + nil, + loadKeychainString: { key in + store[key.storageKey] + }, + persistKeychainString: { key, value in + store[key.storageKey] = value + }, + deleteKeychainValue: { key in + store.removeValue(forKey: key.storageKey) + }, + deleteBitkitSharedIdentities: { + throw SharedPubkyIdentityError.unavailable + }, + clearSessionAccess: { + didClearSessionAccess = true + } + ) + XCTFail("Expected shared mirror deletion failure") + } catch { + XCTAssertEqual(error as? SharedPubkyIdentityError, .unavailable) + } + + XCTAssertFalse(didClearSessionAccess) + XCTAssertEqual(store[KeychainEntryType.paykitSession.storageKey], "stale-session") + XCTAssertEqual(store[KeychainEntryType.pubkySecretKey.storageKey], "local-secret") } func testRestoreSessionBackupStateForLocalSeedDerivesSecretAndClearsSession() async throws { diff --git a/BitkitTests/SharedPubkyIdentityTests.swift b/BitkitTests/SharedPubkyIdentityTests.swift new file mode 100644 index 000000000..33cfe4577 --- /dev/null +++ b/BitkitTests/SharedPubkyIdentityTests.swift @@ -0,0 +1,405 @@ +@testable import Bitkit +import XCTest + +private actor SharedPubkyTestEventLog { + private var events: [String] = [] + + func append(_ event: String) { + events.append(event) + } + + func values() -> [String] { + events + } +} + +final class SharedPubkyIdentityTests: XCTestCase { + private let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + + func testFixtureMatchesRingSecretAndPubkyWireFormats() throws { + let (_, bare, secret) = try identityFixture() + + XCTAssertEqual(secret.count, 64) + XCTAssertNotNil(secret.range(of: "^[0-9a-f]{64}$", options: .regularExpression)) + XCTAssertEqual(bare.count, 52) + XCTAssertEqual(SharedPubkyKeyFormat.normalizedBare(bare), bare) + } + + func testReferenceCanonicalizesPrefixedPubkyToBareWireFormat() throws { + let (prefixed, bare, _) = try identityFixture() + + let reference = try SharedPubkyIdentityRefV1(sourceApp: .ring, pubky: prefixed) + + XCTAssertEqual(reference.pubky, bare) + XCTAssertFalse(reference.pubky.hasPrefix("pubky")) + XCTAssertEqual(reference.pubky.count, 52) + XCTAssertEqual( + SharedPubkyIdentityVault.account(source: .ring, pubky: reference.pubky), + "app.pubkyring:\(bare)" + ) + } + + func testSharedWireFormatRejectsOverlongPubky() throws { + let (_, bare, _) = try identityFixture() + + XCTAssertNil(SharedPubkyKeyFormat.normalizedBare("\(bare)y")) + XCTAssertNil(SharedPubkyKeyFormat.normalizedBare("pubky\(bare)y")) + } + + func testDiscoveryFiltersMalformedAndOtherSourceAccountsWithoutLoadingPayloads() throws { + let (_, bare, _) = try identityFixture() + let accounts = [ + "app.pubkyring:\(bare)", + "app.pubkyring:\(bare)", + "to.bitkit:\(bare)", + "app.pubkyring:not-a-pubky", + "app.pubkyring:\(bare)y", + "unexpected:\(bare)", + ] + + let references = SharedPubkyIdentityVault.references(accounts: accounts, source: .ring) + + XCTAssertEqual(references, try [SharedPubkyIdentityRefV1(sourceApp: .ring, pubky: bare)]) + } + + func testBitkitOwnedDeletionCandidatesPreserveRingOwnedAccount() throws { + let (_, bare, _) = try identityFixture() + let accounts = [ + SharedPubkyIdentityVault.account(source: .ring, pubky: bare), + SharedPubkyIdentityVault.account(source: .bitkit, pubky: bare), + "to.bitkit:malformed-but-owned", + ] + + let exactDeletionAccounts = SharedPubkyIdentityVault.ownedAccounts( + accounts: accounts, + source: .bitkit + ) + + XCTAssertEqual( + exactDeletionAccounts, + ["to.bitkit:\(bare)", "to.bitkit:malformed-but-owned"].sorted() + ) + XCTAssertFalse(exactDeletionAccounts.contains("app.pubkyring:\(bare)")) + } + + func testReconciliationRemovesStaleBitkitMirrorAndPreservesRingMirror() throws { + let (_, bare, _) = try identityFixture() + let staleBare = String(repeating: "y", count: 52) + let currentAccount = SharedPubkyIdentityVault.account(source: .bitkit, pubky: bare) + let staleAccount = SharedPubkyIdentityVault.account(source: .bitkit, pubky: staleBare) + let ringAccount = SharedPubkyIdentityVault.account(source: .ring, pubky: staleBare) + var accounts = [currentAccount, staleAccount, ringAccount] + var deletedAccounts: [String] = [] + + try SharedPubkyIdentityVault.pruneStaleBitkitIdentities( + keeping: currentAccount, + listAccounts: { accounts }, + deleteAccount: { account in + deletedAccounts.append(account) + accounts.removeAll { $0 == account } + } + ) + + XCTAssertEqual(deletedAccounts, [staleAccount]) + XCTAssertEqual(accounts.sorted(), [currentAccount, ringAccount].sorted()) + } + + func testOrphanCleanupDeletesSharedMirrorBeforePrivateAndRNKeychains() throws { + var events: [String] = [] + + try OrphanedKeychainCleanup.perform( + hasNativeKeychain: true, + hasOrphanedRNKeychain: true, + deleteBitkitSharedIdentities: { events.append("shared") }, + wipePrivateKeychain: { events.append("private") }, + cleanupRNKeychain: { events.append("rn") } + ) + + XCTAssertEqual(events, ["shared", "private", "rn"]) + } + + func testOrphanCleanupPreservesPrivateStateWhenSharedMirrorDeletionFails() { + var events: [String] = [] + + XCTAssertThrowsError(try OrphanedKeychainCleanup.perform( + hasNativeKeychain: true, + hasOrphanedRNKeychain: true, + deleteBitkitSharedIdentities: { + events.append("shared") + throw SharedPubkyIdentityError.unavailable + }, + wipePrivateKeychain: { events.append("private") }, + cleanupRNKeychain: { events.append("rn") } + )) + + XCTAssertEqual(events, ["shared"]) + } + + func testCredentialValidationAcceptsMatchingSecret() throws { + let (_, bare, secret) = try identityFixture() + let reference = try SharedPubkyIdentityRefV1(sourceApp: .ring, pubky: bare) + let record = SharedPubkyIdentityRecordV1( + sourceApp: .ring, + pubky: bare, + secretKey: secret + ) + + XCTAssertNoThrow(try SharedPubkyIdentityVault.validate( + record: record, + expected: reference, + derivePublicKey: { try PubkyProfileManager.publicKeyFromSecretKey($0) } + )) + } + + func testCredentialValidationRejectsClaimedKeyMismatch() throws { + let (_, bare, secret) = try identityFixture() + let reference = try SharedPubkyIdentityRefV1(sourceApp: .ring, pubky: bare) + let record = SharedPubkyIdentityRecordV1( + sourceApp: .ring, + pubky: bare, + secretKey: secret + ) + let differentBare = String(repeating: "y", count: 52) + + XCTAssertThrowsError(try SharedPubkyIdentityVault.validate( + record: record, + expected: reference, + derivePublicKey: { _ in "pubky\(differentBare)" } + )) { error in + XCTAssertEqual(error as? SharedPubkyIdentityError, .secretDoesNotMatchPublicKey) + } + } + + func testCredentialValidationRejectsNoncanonicalSecretEncoding() throws { + let (_, bare, secret) = try identityFixture() + let reference = try SharedPubkyIdentityRefV1(sourceApp: .ring, pubky: bare) + let record = SharedPubkyIdentityRecordV1( + sourceApp: .ring, + pubky: bare, + secretKey: secret.uppercased() + ) + + XCTAssertThrowsError(try SharedPubkyIdentityVault.validate( + record: record, + expected: reference, + derivePublicKey: { _ in "pubky\(bare)" } + )) { error in + XCTAssertEqual(error as? SharedPubkyIdentityError, .invalidRecord) + } + } + + func testSharedSessionRestoreKeepsBareReferenceAtWireBoundary() async throws { + let (prefixed, bare, secret) = try identityFixture() + let reference = try SharedPubkyIdentityRefV1(sourceApp: .ring, pubky: bare) + + let result = await PubkyProfileManager.resolveSharedSessionInitialization( + reference: reference, + savedSessionSecret: "saved-session", + sharedSecretKey: secret, + importSession: { session in + XCTAssertEqual(session, "saved-session") + return prefixed + }, + signInWithSharedSecret: { _ in + XCTFail("A valid persisted session should not require the shared credential") + return "unused" + }, + currentPublicKey: { + XCTFail("A valid persisted session should not re-query SDK identity") + return nil + } + ) + + XCTAssertEqual(result, .restored(publicKey: prefixed)) + } + + func testSharedSessionRestoreUsesCredentialWithoutClassifyingItAsLocal() async throws { + let (prefixed, bare, secret) = try identityFixture() + let reference = try SharedPubkyIdentityRefV1(sourceApp: .ring, pubky: bare) + var receivedSecret: String? + + let result = await PubkyProfileManager.resolveSharedSessionInitialization( + reference: reference, + savedSessionSecret: nil, + sharedSecretKey: secret, + importSession: { _ in + XCTFail("No persisted session exists") + return "unused" + }, + signInWithSharedSecret: { value in + receivedSecret = value + return "fresh-session" + }, + currentPublicKey: { prefixed } + ) + + XCTAssertEqual(receivedSecret, secret) + XCTAssertEqual(result, .restored(publicKey: prefixed)) + } + + func testSharedIdentityAdoptionPersistsReferenceBeforeSession() async throws { + let (prefixed, bare, secret) = try identityFixture() + let reference = try SharedPubkyIdentityRefV1(sourceApp: .ring, pubky: bare) + var events: [String] = [] + + let result = try await PubkyProfileManager.establishSharedIdentitySession( + reference: reference, + secretKey: secret, + saveReference: { saved in + XCTAssertEqual(saved, reference) + events.append("reference") + }, + signIn: { receivedSecret in + XCTAssertEqual(receivedSecret, secret) + events.append("session") + return "fresh-session" + }, + currentPublicKey: { prefixed }, + clearSession: { + XCTFail("Successful adoption should not clear its session") + }, + deleteReference: { + XCTFail("Successful adoption should not delete its reference") + } + ) + + XCTAssertEqual(result, prefixed) + XCTAssertEqual(events, ["reference", "session"]) + } + + func testSharedIdentityAdoptionRollsBackReferenceAndSessionOnFailure() async throws { + let (_, bare, secret) = try identityFixture() + let reference = try SharedPubkyIdentityRefV1(sourceApp: .ring, pubky: bare) + var events: [String] = [] + + do { + _ = try await PubkyProfileManager.establishSharedIdentitySession( + reference: reference, + secretKey: secret, + saveReference: { _ in events.append("reference") }, + signIn: { _ in + events.append("session") + throw PubkyServiceError.authFailed("offline") + }, + currentPublicKey: { nil }, + clearSession: { events.append("clear-session") }, + deleteReference: { events.append("delete-reference") } + ) + XCTFail("Expected failed sign-in to roll back") + } catch { + XCTAssertEqual( + events, + ["reference", "session", "clear-session", "delete-reference"] + ) + } + } + + func testSharedIdentityCleanupClearsSessionBeforeReference() async throws { + var events: [String] = [] + + try await PubkyProfileManager.clearSharedIdentitySession( + clearSession: { events.append("session") }, + deleteReference: { events.append("reference") } + ) + + XCTAssertEqual(events, ["session", "reference"]) + } + + func testSharedIdentityCleanupKeepsReferenceWhenSessionDeletionFails() async { + var events: [String] = [] + + do { + try await PubkyProfileManager.clearSharedIdentitySession( + clearSession: { + events.append("session") + throw KeychainError.failedToDelete + }, + deleteReference: { events.append("reference") } + ) + XCTFail("Expected session deletion failure") + } catch { + XCTAssertEqual(events, ["session"]) + } + } + + func testIdentityLifecycleTransactionsDoNotInterleave() async { + let events = SharedPubkyTestEventLog() + let firstStarted = expectation(description: "first lifecycle transaction started") + + async let first: Void = PubkyProfileManager.withIdentityLifecycleLock { + await events.append("first-start") + firstStarted.fulfill() + try? await Task.sleep(nanoseconds: 100_000_000) + await events.append("first-end") + } + await fulfillment(of: [firstStarted], timeout: 1) + async let second: Void = PubkyProfileManager.withIdentityLifecycleLock { + await events.append("second") + } + + _ = await (first, second) + let recordedEvents = await events.values() + XCTAssertEqual(recordedEvents, ["first-start", "first-end", "second"]) + } + + func testActiveIdentityRejectsLocalAndSharedProvenanceCoexistence() throws { + let (prefixed, bare, secret) = try identityFixture() + let reference = try SharedPubkyIdentityRefV1(sourceApp: .ring, pubky: bare) + + XCTAssertThrowsError(try PubkyProfileManager.resolveActiveIdentitySecretKey( + expectedPublicKey: prefixed, + reference: reference, + localSecret: secret, + isSourceAvailable: true, + loadSharedCredential: { _ in + XCTFail("A provenance conflict must fail before reading shared credentials") + return secret + } + )) { error in + XCTAssertEqual(error as? SharedPubkyIdentityError, .provenanceConflict) + } + } + + func testActiveOwnedIdentityMustMatchExpectedPublicKey() throws { + let (_, _, secret) = try identityFixture() + let differentPublicKey = "pubky\(String(repeating: "y", count: 52))" + + XCTAssertThrowsError(try PubkyProfileManager.resolveActiveIdentitySecretKey( + expectedPublicKey: differentPublicKey, + reference: nil, + localSecret: secret, + isSourceAvailable: false, + loadSharedCredential: { _ in secret } + )) { error in + XCTAssertEqual(error as? SharedPubkyIdentityError, .provenanceConflict) + } + } + + func testBorrowedSessionNeverPersistsExportedLocalSecret() throws { + let (_, _, secret) = try identityFixture() + let exportedLocalSecret = try PaykitSdkService.localSecretKey(fromHex: secret) + + XCTAssertNil(try PaykitSdkService.localSecretKeyHexForPersistence( + exportedLocalSecret, + shouldStoreLocalSecret: false + )) + XCTAssertEqual( + try PaykitSdkService.localSecretKeyHexForPersistence( + exportedLocalSecret, + shouldStoreLocalSecret: true + ), + secret + ) + XCTAssertThrowsError(try PaykitSdkService.localSecretKeyHexForPersistence( + nil, + shouldStoreLocalSecret: true + )) + } + + private func identityFixture() throws -> (prefixed: String, bare: String, secret: String) { + let secret = try PubkyService.derivePubkySecretKey(mnemonic: mnemonic) + let prefixed = try PubkyProfileManager.publicKeyFromSecretKey(secret) + let bare = try XCTUnwrap(SharedPubkyKeyFormat.normalizedBare(prefixed)) + return (prefixed, bare, secret) + } +} diff --git a/changelog.d/next/shared-pubky-ring.added.md b/changelog.d/next/shared-pubky-ring.added.md new file mode 100644 index 000000000..b78c83012 --- /dev/null +++ b/changelog.d/next/shared-pubky-ring.added.md @@ -0,0 +1 @@ +Added secure reuse of Pubky Ring profiles through source-owned shared identities, including the redesigned profile choice screen. From 11e17601cb9152f589bde951d5afed3d0aa9e4f3 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Fri, 24 Jul 2026 15:46:47 +0200 Subject: [PATCH 3/5] chore: rename changelog fragment --- changelog.d/next/{shared-pubky-ring.added.md => 643.added.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{shared-pubky-ring.added.md => 643.added.md} (100%) diff --git a/changelog.d/next/shared-pubky-ring.added.md b/changelog.d/next/643.added.md similarity index 100% rename from changelog.d/next/shared-pubky-ring.added.md rename to changelog.d/next/643.added.md From 6c6ee7296755a2813ed9e41d78f5b5a9112c4eeb Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Fri, 24 Jul 2026 16:09:53 +0200 Subject: [PATCH 4/5] fix: preserve bare pubkys beginning with prefix --- Bitkit/Models/PubkyPublicKeyFormat.swift | 15 +++++++++++++-- Bitkit/Models/SharedPubkyIdentity.swift | 11 ++++++++++- BitkitTests/ContactsManagerTests.swift | 9 +++++++++ BitkitTests/SharedPubkyIdentityTests.swift | 7 +++++++ 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/Bitkit/Models/PubkyPublicKeyFormat.swift b/Bitkit/Models/PubkyPublicKeyFormat.swift index 7cd74508c..483038703 100644 --- a/Bitkit/Models/PubkyPublicKeyFormat.swift +++ b/Bitkit/Models/PubkyPublicKeyFormat.swift @@ -13,7 +13,16 @@ enum PubkyPublicKeyFormat { static func normalized(_ input: String) -> String? { let boundedInput = bounded(input) - let rawKey = boundedInput.hasPrefix(prefix) ? String(boundedInput.dropFirst(prefix.count)) : boundedInput + let rawKey: String + if boundedInput.count == rawKeyLength { + rawKey = boundedInput + } else if boundedInput.count == maximumInputLength, + boundedInput.hasPrefix(prefix) + { + rawKey = String(boundedInput.dropFirst(prefix.count)) + } else { + return nil + } guard rawKey.count == rawKeyLength else { return nil @@ -47,7 +56,9 @@ enum PubkyPublicKeyFormat { static func displayTruncated(_ input: String) -> String { let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) - let rawKey = trimmed.lowercased().hasPrefix(prefix) ? String(trimmed.dropFirst(prefix.count)) : trimmed + let rawKey = trimmed.count == maximumInputLength && trimmed.lowercased().hasPrefix(prefix) + ? String(trimmed.dropFirst(prefix.count)) + : trimmed guard rawKey.count > 10 else { return rawKey } return "\(rawKey.prefix(4))...\(rawKey.suffix(4))" diff --git a/Bitkit/Models/SharedPubkyIdentity.swift b/Bitkit/Models/SharedPubkyIdentity.swift index 375602133..4bd1d388b 100644 --- a/Bitkit/Models/SharedPubkyIdentity.swift +++ b/Bitkit/Models/SharedPubkyIdentity.swift @@ -14,7 +14,16 @@ enum SharedPubkyKeyFormat { static func normalizedBare(_ value: String) -> String? { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let bare = trimmed.hasPrefix(prefix) ? String(trimmed.dropFirst(prefix.count)) : trimmed + let bare: String + if trimmed.count == bareKeyLength { + bare = trimmed + } else if trimmed.count == prefix.count + bareKeyLength, + trimmed.hasPrefix(prefix) + { + bare = String(trimmed.dropFirst(prefix.count)) + } else { + return nil + } guard bare.count == bareKeyLength, bare.allSatisfy({ allowedCharacters.contains($0) }) else { diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index 27cc195d1..1f6866031 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -22,6 +22,15 @@ final class ContactsManagerTests: XCTestCase { XCTAssertEqual(PubkyPublicKeyFormat.normalized(prefixedKey), prefixedKey) } + func testPubkyPublicKeyFormatPreservesBareKeyBeginningWithPrefixText() { + let rawKey = "pubky\(String(repeating: "y", count: 47))" + let prefixedKey = "pubky\(rawKey)" + + XCTAssertEqual(PubkyPublicKeyFormat.normalized(rawKey), prefixedKey) + XCTAssertEqual(PubkyPublicKeyFormat.normalized(prefixedKey), prefixedKey) + XCTAssertEqual(PubkyPublicKeyFormat.displayTruncated(rawKey), "pubk...yyyy") + } + func testPubkyPublicKeyFormatRejectsInvalidLengthAndCharacters() { XCTAssertNil(PubkyPublicKeyFormat.normalized("pubkyshort")) XCTAssertNil(PubkyPublicKeyFormat.normalized("pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5x0")) diff --git a/BitkitTests/SharedPubkyIdentityTests.swift b/BitkitTests/SharedPubkyIdentityTests.swift index 33cfe4577..537d3834b 100644 --- a/BitkitTests/SharedPubkyIdentityTests.swift +++ b/BitkitTests/SharedPubkyIdentityTests.swift @@ -25,6 +25,13 @@ final class SharedPubkyIdentityTests: XCTestCase { XCTAssertEqual(SharedPubkyKeyFormat.normalizedBare(bare), bare) } + func testWireKeyBeginningWithPubkyRemainsBare() { + let bare = "pubky\(String(repeating: "y", count: 47))" + + XCTAssertEqual(SharedPubkyKeyFormat.normalizedBare(bare), bare) + XCTAssertEqual(SharedPubkyKeyFormat.normalizedBare("pubky\(bare)"), bare) + } + func testReferenceCanonicalizesPrefixedPubkyToBareWireFormat() throws { let (prefixed, bare, _) = try identityFixture() From dcf89bb8eaba013c42b98b60e0eb2abdffec90c3 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Fri, 24 Jul 2026 16:48:40 +0200 Subject: [PATCH 5/5] fix: align Pubky import overview with designs --- Bitkit/Components/Button/Button.swift | 14 ++++++-- .../Components/Button/PrimaryButtonView.swift | 3 +- .../Button/SecondaryButtonView.swift | 3 +- .../Button/TertiaryButtonView.swift | 3 +- .../Localization/en.lproj/Localizable.strings | 2 +- Bitkit/Styles/TextStyle.swift | 14 +++++--- .../Contacts/ContactImportOverviewView.swift | 35 +++++++++++++------ 7 files changed, 53 insertions(+), 21 deletions(-) diff --git a/Bitkit/Components/Button/Button.swift b/Bitkit/Components/Button/Button.swift index 61582d0bd..cf8231b96 100644 --- a/Bitkit/Components/Button/Button.swift +++ b/Bitkit/Components/Button/Button.swift @@ -66,6 +66,7 @@ struct CustomButton: View { let isDisabled: Bool let isLoading: Bool let shouldExpand: Bool + let labelKerning: CGFloat let background: AnyView? let action: (() async -> Void)? let destination: AnyView? @@ -85,6 +86,7 @@ struct CustomButton: View { isDisabled: Bool = false, isLoading: Bool = false, shouldExpand: Bool = false, + labelKerning: CGFloat = 0.4, background: (any View)? = nil ) { self.title = title @@ -94,6 +96,7 @@ struct CustomButton: View { self.isDisabled = isDisabled self.isLoading = isLoading self.shouldExpand = shouldExpand + self.labelKerning = labelKerning self.background = background.map { AnyView($0) } action = nil destination = nil @@ -108,6 +111,7 @@ struct CustomButton: View { isDisabled: Bool = false, isLoading: Bool = false, shouldExpand: Bool = false, + labelKerning: CGFloat = 0.4, background: (any View)? = nil, action: @escaping () async -> Void ) { @@ -118,6 +122,7 @@ struct CustomButton: View { self.isDisabled = isDisabled self.isLoading = isLoading self.shouldExpand = shouldExpand + self.labelKerning = labelKerning self.background = background.map { AnyView($0) } self.action = action destination = nil @@ -132,6 +137,7 @@ struct CustomButton: View { isDisabled: Bool = false, isLoading: Bool = false, shouldExpand: Bool = false, + labelKerning: CGFloat = 0.4, background: (any View)? = nil, destination: some View ) { @@ -142,6 +148,7 @@ struct CustomButton: View { self.isDisabled = isDisabled self.isLoading = isLoading self.shouldExpand = shouldExpand + self.labelKerning = labelKerning self.background = background.map { AnyView($0) } action = nil self.destination = AnyView(destination) @@ -158,6 +165,7 @@ struct CustomButton: View { isLoading: isLoading, isPressed: isPressed, shouldExpand: shouldExpand, + labelKerning: labelKerning, background: background )) case .secondary: @@ -167,13 +175,15 @@ struct CustomButton: View { icon: icon, isDisabled: effectiveIsDisabled, isPressed: isPressed, - isLoading: isLoading + isLoading: isLoading, + labelKerning: labelKerning )) case .tertiary: AnyView(TertiaryButtonView( title: title, icon: icon, - isPressed: isPressed + isPressed: isPressed, + labelKerning: labelKerning )) } } diff --git a/Bitkit/Components/Button/PrimaryButtonView.swift b/Bitkit/Components/Button/PrimaryButtonView.swift index 00dc27cdb..ce666d5a3 100644 --- a/Bitkit/Components/Button/PrimaryButtonView.swift +++ b/Bitkit/Components/Button/PrimaryButtonView.swift @@ -8,6 +8,7 @@ struct PrimaryButtonView: View { let isLoading: Bool let isPressed: Bool let shouldExpand: Bool + let labelKerning: CGFloat let background: AnyView? var body: some View { @@ -24,7 +25,7 @@ struct PrimaryButtonView: View { if size == .small { CaptionBText(title, textColor: .textPrimary) } else { - BodySSBText(title, textColor: .textPrimary) + BodySSBText(title, textColor: .textPrimary, kerning: labelKerning) } } } diff --git a/Bitkit/Components/Button/SecondaryButtonView.swift b/Bitkit/Components/Button/SecondaryButtonView.swift index c146b9bd5..5c9eeeab9 100644 --- a/Bitkit/Components/Button/SecondaryButtonView.swift +++ b/Bitkit/Components/Button/SecondaryButtonView.swift @@ -7,6 +7,7 @@ struct SecondaryButtonView: View { let isDisabled: Bool let isPressed: Bool var isLoading: Bool = false + let labelKerning: CGFloat var body: some View { HStack(spacing: 8) { @@ -21,7 +22,7 @@ struct SecondaryButtonView: View { } else if size == .small { CaptionBText(title, textColor: textColor) } else { - BodySSBText(title, textColor: textColor) + BodySSBText(title, textColor: textColor, kerning: labelKerning) } } .frame(maxWidth: size == .large ? .infinity : nil) diff --git a/Bitkit/Components/Button/TertiaryButtonView.swift b/Bitkit/Components/Button/TertiaryButtonView.swift index 50f76a3b7..97222e566 100644 --- a/Bitkit/Components/Button/TertiaryButtonView.swift +++ b/Bitkit/Components/Button/TertiaryButtonView.swift @@ -4,6 +4,7 @@ struct TertiaryButtonView: View { let title: String let icon: AnyView? let isPressed: Bool + let labelKerning: CGFloat var body: some View { HStack(spacing: 8) { @@ -11,7 +12,7 @@ struct TertiaryButtonView: View { icon } - BodySSBText(title, textColor: textColor) + BodySSBText(title, textColor: textColor, kerning: labelKerning) } .frame(maxWidth: .infinity) .frame(height: CustomButton.Size.large.height) diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 7b5dfcd51..6e7127042 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1039,7 +1039,7 @@ "contacts__add_error" = "Failed to add contact"; "contacts__add_disclaimer" = "Please note that you and {name} must add each other as contacts to pay each other privately. Otherwise, the payment will be visible publicly."; "contacts__import_nav_title" = "Import"; -"contacts__import_found_title" = "Found\nprofile & contacts"; +"contacts__import_found_title" = "Found\ncontacts"; "contacts__import_found_description" = "Bitkit found profile and contacts data connected to pubky {key}"; "contacts__import_select" = "Select"; "contacts__import_all" = "Import All"; diff --git a/Bitkit/Styles/TextStyle.swift b/Bitkit/Styles/TextStyle.swift index 33efa68de..3cee31df4 100644 --- a/Bitkit/Styles/TextStyle.swift +++ b/Bitkit/Styles/TextStyle.swift @@ -205,19 +205,22 @@ struct BodyMSBText: View { var accentAction: (() -> Void)? private let fontSize: CGFloat = 17 + private let kerningValue: CGFloat init( _ text: String, textColor: Color = .textPrimary, accentColor: Color = .brandAccent, accentFont: ((CGFloat) -> Font)? = nil, - accentAction: (() -> Void)? = nil + accentAction: (() -> Void)? = nil, + kerning: CGFloat = 0.4 ) { self.text = text self.textColor = textColor self.accentColor = accentColor self.accentFont = accentFont self.accentAction = accentAction + kerningValue = kerning } var body: some View { @@ -229,7 +232,7 @@ struct BodyMSBText: View { accentFont: accentFont?(fontSize), accentAction: accentAction ) - .kerning(0.4) + .kerning(kerningValue) } } @@ -313,19 +316,22 @@ struct BodySSBText: View { var accentAction: (() -> Void)? private let fontSize: CGFloat = 15 + private let kerningValue: CGFloat init( _ text: String, textColor: Color = .textPrimary, accentColor: Color = .brandAccent, accentFont: ((CGFloat) -> Font)? = nil, - accentAction: (() -> Void)? = nil + accentAction: (() -> Void)? = nil, + kerning: CGFloat = 0.4 ) { self.text = text self.textColor = textColor self.accentColor = accentColor self.accentFont = accentFont self.accentAction = accentAction + kerningValue = kerning } var body: some View { @@ -337,7 +343,7 @@ struct BodySSBText: View { accentFont: accentFont?(fontSize), accentAction: accentAction ) - .kerning(0.4) + .kerning(kerningValue) // .lineSpacing(0) } } diff --git a/Bitkit/Views/Contacts/ContactImportOverviewView.swift b/Bitkit/Views/Contacts/ContactImportOverviewView.swift index 7f5ea112a..b95340787 100644 --- a/Bitkit/Views/Contacts/ContactImportOverviewView.swift +++ b/Bitkit/Views/Contacts/ContactImportOverviewView.swift @@ -19,7 +19,11 @@ struct ContactImportOverviewView: View { var body: some View { VStack(spacing: 0) { - NavigationBar(title: t("contacts__import_nav_title")) + NavigationBar(title: "") + .overlay { + TitleText(t("contacts__import_nav_title")) + .allowsHitTesting(false) + } .padding(.horizontal, 16) ScrollView { @@ -28,19 +32,20 @@ struct ContactImportOverviewView: View { t("contacts__import_found_title"), accentColor: .pubkyGreen ) - .padding(.top, 24) - .padding(.bottom, 8) + .padding(.top, 20) + .padding(.bottom, 14) BodyMText( t("contacts__import_found_description", variables: ["key": profile.truncatedPublicKey]), accentColor: .white, - accentFont: Fonts.bold + accentFont: Fonts.bold, + kerning: 0 ) .fixedSize(horizontal: false, vertical: true) - .padding(.bottom, 32) + .padding(.bottom, 33.5) profileRow - .padding(.bottom, 24) + .padding(.bottom, 32) contactsSummary } @@ -49,7 +54,7 @@ struct ContactImportOverviewView: View { Spacer() - BottomActionBar { + BottomActionBar(bottomPadding: 0) { buttonBar } } @@ -62,7 +67,7 @@ struct ContactImportOverviewView: View { // MARK: - Profile Row private var profileRow: some View { - HStack(alignment: .top, spacing: 16) { + HStack(alignment: .center, spacing: 16) { HeadlineText(profile.name) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) @@ -83,7 +88,10 @@ struct ContactImportOverviewView: View { private var contactsSummary: some View { HStack(spacing: 16) { - BodyMSBText(t("contacts__import_friends_count", variables: ["count": "\(contacts.count)"])) + BodyMSBText( + t("contacts__import_friends_count", variables: ["count": "\(contacts.count)"]), + kerning: 0 + ) Spacer() @@ -151,14 +159,19 @@ struct ContactImportOverviewView: View { private var buttonBar: some View { HStack(spacing: 16) { - CustomButton(title: t("contacts__import_select"), variant: .secondary) { + CustomButton( + title: t("contacts__import_select"), + variant: .secondary, + labelKerning: 0 + ) { navigation.navigate(.contactImportSelect) } .accessibilityIdentifier("ContactImportOverviewSelect") CustomButton( title: t("contacts__import_all"), - isLoading: isImporting + isLoading: isImporting, + labelKerning: 0 ) { await importAllContacts() }