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