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/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/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/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/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
new file mode 100644
index 000000000..4bd1d388b
--- /dev/null
+++ b/Bitkit/Models/SharedPubkyIdentity.swift
@@ -0,0 +1,123 @@
+import Foundation
+
+enum SharedPubkyIdentitySource: String, Codable, Equatable {
+ case ring = "app.pubkyring"
+ case bitkit = "to.bitkit"
+}
+
+enum SharedPubkyKeyFormat {
+ private static let prefix = "pubky"
+ private static let bareKeyLength = 52
+ private static let allowedCharacters = Set("ybndrfg8ejkmcpqxot1uwisza345h769")
+ private static let secretKeyLength = 64
+ private static let secretKeyCharacters = Set("0123456789abcdef")
+
+ static func normalizedBare(_ value: String) -> String? {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ let bare: String
+ if trimmed.count == bareKeyLength {
+ bare = trimmed
+ } else if trimmed.count == prefix.count + bareKeyLength,
+ trimmed.hasPrefix(prefix)
+ {
+ bare = String(trimmed.dropFirst(prefix.count))
+ } else {
+ return nil
+ }
+ guard bare.count == bareKeyLength,
+ bare.allSatisfy({ allowedCharacters.contains($0) })
+ else {
+ return nil
+ }
+ return bare
+ }
+
+ static func isCanonicalSecretKey(_ value: String) -> Bool {
+ value.count == secretKeyLength &&
+ value.allSatisfy { secretKeyCharacters.contains($0) }
+ }
+
+ static func prefixed(_ bareValue: String) -> String? {
+ guard let bare = normalizedBare(bareValue), bare == bareValue else {
+ return nil
+ }
+ return "\(prefix)\(bare)"
+ }
+}
+
+/// App-private pointer to an identity whose canonical secret remains owned by another app.
+struct SharedPubkyIdentityRefV1: Codable, Equatable {
+ static let currentVersion = 1
+
+ let version: Int
+ let sourceApp: SharedPubkyIdentitySource
+ let pubky: String
+
+ init(sourceApp: SharedPubkyIdentitySource, pubky: String) throws {
+ guard let normalizedPubky = SharedPubkyKeyFormat.normalizedBare(pubky) else {
+ throw SharedPubkyIdentityError.invalidPublicKey
+ }
+
+ version = Self.currentVersion
+ self.sourceApp = sourceApp
+ self.pubky = normalizedPubky
+ }
+}
+
+/// Payload stored in the shared Keychain access group by the app that owns the identity.
+struct SharedPubkyIdentityRecordV1: Codable, Equatable {
+ static let currentVersion = 1
+
+ let version: Int
+ let sourceApp: SharedPubkyIdentitySource
+ let pubky: String
+ let secretKey: String
+
+ init(sourceApp: SharedPubkyIdentitySource, pubky: String, secretKey: String) {
+ version = Self.currentVersion
+ self.sourceApp = sourceApp
+ self.pubky = pubky
+ self.secretKey = secretKey
+ }
+}
+
+struct SharedPubkyIdentityOption: Identifiable {
+ let reference: SharedPubkyIdentityRefV1
+ let profile: PubkyProfile
+
+ var id: String {
+ reference.pubky
+ }
+}
+
+enum SharedPubkyIdentityError: LocalizedError, Equatable {
+ case unavailable
+ case 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..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";
@@ -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..3cee31df4 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)
}
}
@@ -202,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 {
@@ -226,7 +232,7 @@ struct BodyMSBText: View {
accentFont: accentFont?(fontSize),
accentAction: accentAction
)
- .kerning(0.4)
+ .kerning(kerningValue)
}
}
@@ -310,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 {
@@ -334,7 +343,7 @@ struct BodySSBText: View {
accentFont: accentFont?(fontSize),
accentAction: accentAction
)
- .kerning(0.4)
+ .kerning(kerningValue)
// .lineSpacing(0)
}
}
diff --git a/Bitkit/Utilities/AppReset.swift b/Bitkit/Utilities/AppReset.swift
index 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/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
diff --git a/Bitkit/Views/Contacts/ContactImportOverviewView.swift b/Bitkit/Views/Contacts/ContactImportOverviewView.swift
index 7f5ea112a..b95340787 100644
--- a/Bitkit/Views/Contacts/ContactImportOverviewView.swift
+++ b/Bitkit/Views/Contacts/ContactImportOverviewView.swift
@@ -19,7 +19,11 @@ struct ContactImportOverviewView: View {
var body: some View {
VStack(spacing: 0) {
- NavigationBar(title: t("contacts__import_nav_title"))
+ NavigationBar(title: "")
+ .overlay {
+ TitleText(t("contacts__import_nav_title"))
+ .allowsHitTesting(false)
+ }
.padding(.horizontal, 16)
ScrollView {
@@ -28,19 +32,20 @@ struct ContactImportOverviewView: View {
t("contacts__import_found_title"),
accentColor: .pubkyGreen
)
- .padding(.top, 24)
- .padding(.bottom, 8)
+ .padding(.top, 20)
+ .padding(.bottom, 14)
BodyMText(
t("contacts__import_found_description", variables: ["key": profile.truncatedPublicKey]),
accentColor: .white,
- accentFont: Fonts.bold
+ accentFont: Fonts.bold,
+ kerning: 0
)
.fixedSize(horizontal: false, vertical: true)
- .padding(.bottom, 32)
+ .padding(.bottom, 33.5)
profileRow
- .padding(.bottom, 24)
+ .padding(.bottom, 32)
contactsSummary
}
@@ -49,7 +54,7 @@ struct ContactImportOverviewView: View {
Spacer()
- BottomActionBar {
+ BottomActionBar(bottomPadding: 0) {
buttonBar
}
}
@@ -62,7 +67,7 @@ struct ContactImportOverviewView: View {
// MARK: - Profile Row
private var profileRow: some View {
- HStack(alignment: .top, spacing: 16) {
+ HStack(alignment: .center, spacing: 16) {
HeadlineText(profile.name)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -83,7 +88,10 @@ struct ContactImportOverviewView: View {
private var contactsSummary: some View {
HStack(spacing: 16) {
- BodyMSBText(t("contacts__import_friends_count", variables: ["count": "\(contacts.count)"]))
+ BodyMSBText(
+ t("contacts__import_friends_count", variables: ["count": "\(contacts.count)"]),
+ kerning: 0
+ )
Spacer()
@@ -151,14 +159,19 @@ struct ContactImportOverviewView: View {
private var buttonBar: some View {
HStack(spacing: 16) {
- CustomButton(title: t("contacts__import_select"), variant: .secondary) {
+ CustomButton(
+ title: t("contacts__import_select"),
+ variant: .secondary,
+ labelKerning: 0
+ ) {
navigation.navigate(.contactImportSelect)
}
.accessibilityIdentifier("ContactImportOverviewSelect")
CustomButton(
title: t("contacts__import_all"),
- isLoading: isImporting
+ isLoading: isImporting,
+ labelKerning: 0
) {
await importAllContacts()
}
diff --git a/Bitkit/Views/Profile/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/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/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..537d3834b
--- /dev/null
+++ b/BitkitTests/SharedPubkyIdentityTests.swift
@@ -0,0 +1,412 @@
+@testable import Bitkit
+import XCTest
+
+private actor SharedPubkyTestEventLog {
+ private var events: [String] = []
+
+ func append(_ event: String) {
+ events.append(event)
+ }
+
+ func values() -> [String] {
+ events
+ }
+}
+
+final class SharedPubkyIdentityTests: XCTestCase {
+ private let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
+
+ func testFixtureMatchesRingSecretAndPubkyWireFormats() throws {
+ let (_, bare, secret) = try identityFixture()
+
+ XCTAssertEqual(secret.count, 64)
+ XCTAssertNotNil(secret.range(of: "^[0-9a-f]{64}$", options: .regularExpression))
+ XCTAssertEqual(bare.count, 52)
+ XCTAssertEqual(SharedPubkyKeyFormat.normalizedBare(bare), bare)
+ }
+
+ func testWireKeyBeginningWithPubkyRemainsBare() {
+ let bare = "pubky\(String(repeating: "y", count: 47))"
+
+ XCTAssertEqual(SharedPubkyKeyFormat.normalizedBare(bare), bare)
+ XCTAssertEqual(SharedPubkyKeyFormat.normalizedBare("pubky\(bare)"), bare)
+ }
+
+ func testReferenceCanonicalizesPrefixedPubkyToBareWireFormat() throws {
+ let (prefixed, bare, _) = try identityFixture()
+
+ let reference = try SharedPubkyIdentityRefV1(sourceApp: .ring, pubky: prefixed)
+
+ XCTAssertEqual(reference.pubky, bare)
+ XCTAssertFalse(reference.pubky.hasPrefix("pubky"))
+ XCTAssertEqual(reference.pubky.count, 52)
+ XCTAssertEqual(
+ SharedPubkyIdentityVault.account(source: .ring, pubky: reference.pubky),
+ "app.pubkyring:\(bare)"
+ )
+ }
+
+ func 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/643.added.md b/changelog.d/next/643.added.md
new file mode 100644
index 000000000..b78c83012
--- /dev/null
+++ b/changelog.d/next/643.added.md
@@ -0,0 +1 @@
+Added secure reuse of Pubky Ring profiles through source-owned shared identities, including the redesigned profile choice screen.