diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 90fe1390..ac7034f1 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -232,9 +232,21 @@ struct MainNavView: View { return } - // Check if this is a Paykit payment request - if url.scheme == "paykit" || (url.scheme == "bitkit" && url.host == "payment-request") { - await handlePaymentRequestDeepLink(url: url, app: app, sheets: sheets) + // Check if this is a Paykit payment request using secure validator + if PaykitDeepLinkValidator.isPaykitURL(url) { + // Validate before processing + switch PaykitDeepLinkValidator.validate(url) { + case .valid(let requestId, let fromPubkey): + await handlePaymentRequestDeepLink( + requestId: requestId, + fromPubkey: fromPubkey, + app: app, + sheets: sheets + ) + case .invalid(let reason): + Logger.error("Invalid Paykit deep link: \(reason)", context: "MainNavView") + app.toast(type: .error, title: "Invalid Request", description: reason) + } return } @@ -566,24 +578,21 @@ struct MainNavView: View { } #endif - /// Handle payment request deep links - /// Format: paykit://payment-request?requestId=xxx&from=yyy - /// or: bitkit://payment-request?requestId=xxx&from=yyy - private func handlePaymentRequestDeepLink(url: URL, app: AppViewModel, sheets: SheetViewModel) async { - guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), - let queryItems = components.queryItems else { - app.toast(type: .error, title: "Invalid Request", description: "Could not parse payment request URL") - return - } - - let requestId = queryItems.first(where: { $0.name == "requestId" })?.value - let fromPubkey = queryItems.first(where: { $0.name == "from" })?.value - - guard let requestId = requestId, let fromPubkey = fromPubkey else { - app.toast(type: .error, title: "Invalid Request", description: "Payment request URL is missing required parameters") - return - } - + /// Handle payment request deep links with pre-validated parameters. + /// + /// Parameters are already validated by `PaykitDeepLinkValidator` before this method is called. + /// + /// - Parameters: + /// - requestId: The validated payment request ID. + /// - fromPubkey: The validated sender's public key. + /// - app: The app view model. + /// - sheets: The sheet view model. + private func handlePaymentRequestDeepLink( + requestId: String, + fromPubkey: String, + app: AppViewModel, + sheets: SheetViewModel + ) async { Logger.info("Processing payment request: \(requestId) from \(fromPubkey.prefix(16))...", context: "MainNavView") // Check if PaykitManager is initialized, try to initialize if not diff --git a/Bitkit/PaykitIntegration/KeyManager.swift b/Bitkit/PaykitIntegration/KeyManager.swift index 02204fe0..b535e1ce 100644 --- a/Bitkit/PaykitIntegration/KeyManager.swift +++ b/Bitkit/PaykitIntegration/KeyManager.swift @@ -2,14 +2,21 @@ // KeyManager.swift // Bitkit // -// Manages Ed25519 identity keys and X25519 device keys for Paykit -// Uses Bitkit's Keychain for secure storage +// Manages device identity and X25519 noise keys for Paykit +// Ed25519 master keys are owned by Pubky Ring - Bitkit only caches derived keys // import Foundation // PaykitMobile types are available from FFI/PaykitMobile.swift -/// Manages Ed25519 identity keys and X25519 device keys for Paykit +/// Manages device identity and X25519 noise keys for Paykit +/// +/// SECURITY: Ed25519 master keys are owned exclusively by Pubky Ring. +/// Bitkit only stores: +/// - Public key (z-base32) for identification +/// - Device ID for key derivation context +/// - Epoch for key rotation +/// - Cached X25519 noise keypairs (derived by Ring) public final class PaykitKeyManager { public static let shared = PaykitKeyManager() @@ -17,11 +24,10 @@ public final class PaykitKeyManager { private let keychain: PaykitKeychainStorage private enum Keys { - static let secretKey = "paykit.identity.secret" - static let publicKey = "paykit.identity.public" static let publicKeyZ32 = "paykit.identity.public.z32" static let deviceId = "paykit.device.id" static let epoch = "paykit.device.epoch" + static let noiseKeypairPrefix = "paykit.noise.keypair." } private var deviceId: String { @@ -46,36 +52,11 @@ public final class PaykitKeyManager { self.keychain = PaykitKeychainStorage() } - /// Get or create Ed25519 identity - public func getOrCreateIdentity() async throws -> Ed25519Keypair { - if let secretData = try? keychain.retrieve(key: Keys.secretKey), - let secretHex = String(data: secretData, encoding: .utf8) { - return try ed25519KeypairFromSecret(secretKeyHex: secretHex) - } - return try await generateNewIdentity() - } - - /// Generate a new Ed25519 identity - public func generateNewIdentity() async throws -> Ed25519Keypair { - let keypair = try generateEd25519Keypair() - - // Store in keychain - try keychain.store(key: Keys.secretKey, data: keypair.secretKeyHex.data(using: .utf8)!) - try keychain.store(key: Keys.publicKey, data: keypair.publicKeyHex.data(using: .utf8)!) - try keychain.store(key: Keys.publicKeyZ32, data: keypair.publicKeyZ32.data(using: .utf8)!) - - return keypair - } + // MARK: - Public Key (from Ring) - /// Store an existing identity (e.g., from Pubky Ring session) - public func storeIdentity(secretKeyHex: String, publicKeyZ32: String) throws { - // Store in keychain - try keychain.store(key: Keys.secretKey, data: secretKeyHex.data(using: .utf8)!) - try keychain.store(key: Keys.publicKeyZ32, data: publicKeyZ32.data(using: .utf8)!) - - // Derive and store publicKeyHex from secret - let keypair = try ed25519KeypairFromSecret(secretKeyHex: secretKeyHex) - try keychain.store(key: Keys.publicKey, data: keypair.publicKeyHex.data(using: .utf8)!) + /// Store public key received from Pubky Ring + public func storePublicKey(pubkeyZ32: String) throws { + try keychain.store(key: Keys.publicKeyZ32, data: pubkeyZ32.data(using: .utf8)!) } /// Get current public key in z-base32 format @@ -87,57 +68,73 @@ public final class PaykitKeyManager { return pubkey } - /// Get current secret key hex - public func getSecretKeyHex() -> String? { - guard let data = try? keychain.retrieve(key: Keys.secretKey), - let secret = String(data: data, encoding: .utf8) else { - return nil - } - return secret - } - - /// Get secret key as bytes - public func getSecretKeyBytes() -> Data? { - guard let hex = getSecretKeyHex() else { return nil } - return Data(hex: hex) + /// Check if we have an identity configured + public var hasIdentity: Bool { + return getCurrentPublicKeyZ32() != nil } - /// Derive X25519 keypair for Noise protocol - public func deriveNoiseKeypair(epoch: UInt32? = nil) async throws -> X25519Keypair { - guard let secretHex = getSecretKeyHex() else { - throw PaykitKeyError.noIdentity - } - let deviceIdValue = self.deviceId - let epochValue = epoch ?? currentEpoch - - return try deriveX25519Keypair( - ed25519SecretHex: secretHex, - deviceId: deviceIdValue, - epoch: epochValue - ) - } + // MARK: - Device Management - /// Get device ID + /// Get device ID (used for key derivation context) public func getDeviceId() -> String { return deviceId } - /// Get current epoch + /// Get current epoch (used for key rotation) public func getCurrentEpoch() -> UInt32 { return currentEpoch } + /// Set current epoch to a specific value + /// Used for key rotation when switching to a pre-cached epoch + public func setCurrentEpoch(_ epoch: UInt32) { + try? keychain.store(key: Keys.epoch, data: String(epoch).data(using: .utf8)!) + } + /// Rotate keys by incrementing epoch - public func rotateKeys() async throws { + public func rotateKeys() throws { let newEpoch = currentEpoch + 1 try keychain.store(key: Keys.epoch, data: String(newEpoch).data(using: .utf8)!) } - /// Delete identity + // MARK: - X25519 Noise Keypair Caching + + /// Cache an X25519 noise keypair received from Pubky Ring + /// - Parameters: + /// - keypair: The X25519 keypair from Ring + /// - epoch: The epoch this keypair was derived for + public func cacheNoiseKeypair(_ keypair: X25519Keypair, epoch: UInt32) throws { + let key = noiseKeypairKey(epoch: epoch) + let data = try encodeKeypair(keypair) + try keychain.store(key: key, data: data) + } + + /// Get cached X25519 noise keypair for a given epoch + /// - Parameter epoch: The epoch to retrieve keypair for (defaults to current) + /// - Returns: The cached keypair, or nil if not cached + public func getCachedNoiseKeypair(epoch: UInt32? = nil) -> X25519Keypair? { + let epochValue = epoch ?? currentEpoch + let key = noiseKeypairKey(epoch: epochValue) + guard let data = try? keychain.retrieve(key: key) else { + return nil + } + return try? decodeKeypair(data) + } + + /// Check if we have a cached noise keypair for the current epoch + public var hasNoiseKeypair: Bool { + return getCachedNoiseKeypair() != nil + } + + // MARK: - Cleanup + + /// Delete all Paykit identity data public func deleteIdentity() throws { - try? keychain.delete(key: Keys.secretKey) - try? keychain.delete(key: Keys.publicKey) try? keychain.delete(key: Keys.publicKeyZ32) + // Clean up noise keypairs for epochs 0-10 (reasonable range) + for epoch in 0..<10 { + try? keychain.delete(key: noiseKeypairKey(epoch: UInt32(epoch))) + } } // MARK: - Private @@ -145,15 +142,43 @@ public final class PaykitKeyManager { private func generateNewDeviceId() -> String { return UUID().uuidString } + + private func noiseKeypairKey(epoch: UInt32) -> String { + return "\(Keys.noiseKeypairPrefix)\(deviceId).\(epoch)" + } + + private func encodeKeypair(_ keypair: X25519Keypair) throws -> Data { + // Store as JSON for simplicity + let dict: [String: String] = [ + "publicKeyHex": keypair.publicKeyHex, + "secretKeyHex": keypair.secretKeyHex + ] + return try JSONSerialization.data(withJSONObject: dict) + } + + private func decodeKeypair(_ data: Data) throws -> X25519Keypair { + guard let dict = try JSONSerialization.jsonObject(with: data) as? [String: String], + let publicKeyHex = dict["publicKeyHex"], + let secretKeyHex = dict["secretKeyHex"] else { + throw PaykitKeyError.invalidKeypairData + } + return X25519Keypair(publicKeyHex: publicKeyHex, secretKeyHex: secretKeyHex) + } } enum PaykitKeyError: LocalizedError { case noIdentity + case noNoiseKeypair + case invalidKeypairData var errorDescription: String? { switch self { case .noIdentity: - return "No identity configured. Please set up your identity first." + return "No identity configured. Please connect to Pubky Ring first." + case .noNoiseKeypair: + return "No noise keypair available. Please reconnect to Pubky Ring." + case .invalidKeypairData: + return "Failed to decode cached keypair data." } } } diff --git a/Bitkit/PaykitIntegration/PaykitManager.swift b/Bitkit/PaykitIntegration/PaykitManager.swift index 73a3e11c..ca05dd87 100644 --- a/Bitkit/PaykitIntegration/PaykitManager.swift +++ b/Bitkit/PaykitIntegration/PaykitManager.swift @@ -91,15 +91,18 @@ public final class PaykitManager { Logger.info("Registering Paykit executors", context: "PaykitManager") - bitcoinExecutor = BitkitBitcoinExecutor() - lightningExecutor = BitkitLightningExecutor() + let btcExecutor = BitkitBitcoinExecutor() + let lnExecutor = BitkitLightningExecutor() guard let client = client else { throw PaykitManagerError.notInitialized } - try client.registerBitcoinExecutor(executor: bitcoinExecutor!) - try client.registerLightningExecutor(executor: lightningExecutor!) + try client.registerBitcoinExecutor(executor: btcExecutor) + try client.registerLightningExecutor(executor: lnExecutor) + // Store references after successful registration + bitcoinExecutor = btcExecutor + lightningExecutor = lnExecutor hasExecutors = true Logger.info("Paykit executors registered successfully", context: "PaykitManager") } diff --git a/Bitkit/PaykitIntegration/README.md b/Bitkit/PaykitIntegration/README.md index 8408f81a..f0104fe4 100644 --- a/Bitkit/PaykitIntegration/README.md +++ b/Bitkit/PaykitIntegration/README.md @@ -2,6 +2,20 @@ This module integrates the Paykit payment coordination protocol with Bitkit iOS. +## Production Integration Guide + +**For complete production integration instructions, see:** +- **[Bitkit + Paykit Integration Master Guide](https://github.com/BitcoinErrorLog/paykit-rs/blob/main/docs/BITKIT_PAYKIT_INTEGRATION_MASTERGUIDE.md)** + +This comprehensive guide covers: +- Building paykit-rs and pubky-noise from source +- XCFramework integration (PaykitMobile + PubkyNoise) +- Pubky Ring communication and session management +- Key derivation and NoiseKeyCache +- Complete Noise protocol payment flows +- Background tasks (SessionRefreshService, PaykitPollingService) +- Production configuration and security checklist + ## Overview Paykit enables Bitkit to execute payments through a standardized protocol that supports: @@ -187,6 +201,81 @@ Ensure `PaykitIntegrationHelper.setup()` is called during app startup. See inline documentation in source files for detailed API reference. +## Thread Safety & Security + +### Thread-Safe Services + +All Paykit services use proper synchronization for thread safety: + +| Service | Mechanism | Purpose | +|---------|-----------|---------| +| `PubkyRingBridge` | `NSLock` | Protects session and keypair caches | +| `SpendingLimitManager` | `DispatchQueue.sync` | Serializes FFI access | +| `NoiseKeyCache` | Barrier sync | Atomic read-check-write operations | +| `PaykitReceiptStore` | Concurrent queue | Thread-safe receipt storage | + +### Secure Storage + +Sensitive data is stored in Keychain (encrypted at rest): + +- **Receipts**: `PaykitReceiptStore` uses `PaykitKeychainStorage` +- **Sessions**: `PubkyRingBridge` persists to Keychain +- **Keys**: `NoiseKeyCache` uses Keychain for X25519 keys + +### Deep Link Validation + +Use `PaykitDeepLinkValidator` for secure deep link handling: + +```swift +switch PaykitDeepLinkValidator.validate(url) { +case .valid(let requestId, let fromPubkey): + // Process validated parameters + handlePaymentRequest(requestId: requestId, from: fromPubkey) +case .invalid(let reason): + // Show error to user + showError(reason) +} +``` + +Validation includes: +- Scheme validation (`paykit://` or `bitkit://payment-request`) +- Required parameter checks (`requestId`, `from`) +- Length limits (prevents buffer overflow attacks) +- Character validation (alphanumeric only) + +### Biometric Policy for Background Payments + +Background payments (subscriptions, auto-pay) cannot use biometric authentication +because there is no UI to present the prompt. + +**Policy:** +- Use `AutoPayEvaluatorService.evaluateForBackground()` for background contexts +- Returns `.needsApproval` instead of `.needsBiometric` +- Sends local notification prompting user to open app + +**Configuration:** +- Set `AutoPaySettings.biometricForLarge` to control threshold (default: 100,000 sats) +- For fully automatic background payments, ensure amounts are below threshold +- Or disable `biometricForLarge` for subscription payments + +### Shared Network Configuration + +Use `PaykitNetworkConfig.shared.session` for consistent network settings: + +```swift +// Pre-configured with appropriate timeouts and security settings +let session = PaykitNetworkConfig.shared.session + +// Or with custom timeout +let customSession = PaykitNetworkConfig.shared.sessionWithTimeout(120) +``` + +Configuration: +- 30 second request timeout +- 60 second resource timeout +- HTTP/2 enabled +- URL caching disabled (sensitive payment data) + ## Phase 6: Production Hardening ### Logging & Monitoring diff --git a/Bitkit/PaykitIntegration/Services/AutoPayEvaluatorService.swift b/Bitkit/PaykitIntegration/Services/AutoPayEvaluatorService.swift new file mode 100644 index 00000000..e169b067 --- /dev/null +++ b/Bitkit/PaykitIntegration/Services/AutoPayEvaluatorService.swift @@ -0,0 +1,201 @@ +// +// AutoPayEvaluatorService.swift +// Bitkit +// +// Non-MainActor auto-pay evaluation service for background and worker contexts. +// Extracted from AutoPayViewModel for use in SubscriptionBackgroundService. +// + +import Foundation + +/// Auto-pay evaluator service for background contexts. +/// +/// This service encapsulates auto-pay evaluation logic without MainActor constraints, +/// making it suitable for use in BGTaskScheduler handlers and other background contexts. +/// +/// ## Biometric Policy for Background Payments +/// +/// Background payments (e.g., subscription auto-renewals) cannot use biometric +/// authentication because there is no user interface to present the prompt. +/// +/// **Policy:** +/// - Payments requiring biometric auth (`.needsBiometric`) are treated as `.needsApproval` +/// - A local notification is sent to prompt the user to open the app +/// - The payment is deferred until the user manually approves it +/// +/// **Configuration:** +/// - Set `AutoPaySettings.biometricForLarge` to control the threshold +/// - For fully automatic background payments, ensure amounts are below this threshold +/// - Or disable `biometricForLarge` to skip biometric requirements entirely +public final class AutoPayEvaluatorService { + + // MARK: - Properties + + private let autoPayStorage: AutoPayStorage + private let identityName: String + + // MARK: - Initialization + + public init(identityName: String = "default") { + self.identityName = identityName + self.autoPayStorage = AutoPayStorage(identityName: identityName) + } + + // MARK: - Evaluation + + /// Evaluate if a payment should be auto-approved. + /// + /// This method is thread-safe and can be called from any context (including background tasks). + /// + /// - Parameters: + /// - peerPubkey: The peer's public key. + /// - peerName: The peer's display name. + /// - amount: Payment amount in satoshis. + /// - methodId: The payment method identifier. + /// - isSubscription: Whether this is a subscription payment. + /// - Returns: The evaluation result. + public func evaluate( + peerPubkey: String, + peerName: String, + amount: Int64, + methodId: String, + isSubscription: Bool = false + ) -> AutopayEvaluationResult { + let settings = autoPayStorage.getSettings() + let peerLimits = autoPayStorage.getPeerLimits() + let rules = autoPayStorage.getRules() + + // Check if autopay is enabled + guard settings.isEnabled else { + return .denied(reason: "Auto-pay is disabled") + } + + // Check per-payment limit + if amount > settings.maxPerPayment { + if settings.confirmHighValue { + return .needsApproval + } + return .denied(reason: "Exceeds max per payment") + } + + // Check global daily limit + let spentToday = calculateSpentToday() + if spentToday + amount > settings.globalDailyLimit { + return .denied(reason: "Would exceed daily limit") + } + + // Check if first payment to peer requires confirmation + let isNewPeer = !peerLimits.contains { $0.peerPubkey == peerPubkey } + if isNewPeer && settings.confirmFirstPayment { + return .needsApproval + } + + // Check subscription confirmation requirement + if isSubscription && settings.confirmSubscriptions { + return .needsApproval + } + + // Check biometric for large amounts + // Note: Background payments cannot show biometric prompts + if settings.biometricForLarge && amount > 100_000 { + return .needsBiometric + } + + // Check peer-specific limit + if let peerLimit = peerLimits.first(where: { $0.peerPubkey == peerPubkey }) { + var mutableLimit = peerLimit + mutableLimit.resetIfNeeded() + + // Update storage if reset occurred + if mutableLimit.spentSats != peerLimit.spentSats { + try? autoPayStorage.savePeerLimit(mutableLimit) + } + + if mutableLimit.spentSats + amount > mutableLimit.limitSats { + return .denied(reason: "Would exceed peer limit") + } + } + + // Check auto-pay rules + for rule in rules where rule.isEnabled { + if rule.matches(amount: amount, method: methodId, peer: peerPubkey) { + return .approved(ruleId: rule.id, ruleName: rule.name) + } + } + + return .needsApproval + } + + /// Evaluate for background context, treating biometric requirement as needs approval. + /// + /// This method should be used in background tasks where biometric prompts are not possible. + /// + /// - Parameters: + /// - peerPubkey: The peer's public key. + /// - peerName: The peer's display name. + /// - amount: Payment amount in satoshis. + /// - methodId: The payment method identifier. + /// - isSubscription: Whether this is a subscription payment. + /// - Returns: The evaluation result (never returns `.needsBiometric`). + public func evaluateForBackground( + peerPubkey: String, + peerName: String, + amount: Int64, + methodId: String, + isSubscription: Bool = false + ) -> AutopayEvaluationResult { + let result = evaluate( + peerPubkey: peerPubkey, + peerName: peerName, + amount: amount, + methodId: methodId, + isSubscription: isSubscription + ) + + // Convert biometric requirement to needs approval for background contexts + if case .needsBiometric = result { + return .needsApproval + } + + return result + } + + // MARK: - Helpers + + private func calculateSpentToday() -> Int64 { + let history = autoPayStorage.getHistory() + let calendar = Calendar.current + let startOfDay = calendar.startOfDay(for: Date()) + + return history + .filter { $0.timestamp >= startOfDay && $0.wasApproved } + .reduce(0) { $0 + $1.amount } + } + + /// Record a payment in the auto-pay history. + /// + /// - Parameters: + /// - peerPubkey: The peer's public key. + /// - peerName: The peer's display name. + /// - amount: Payment amount in satoshis. + /// - approved: Whether the payment was approved. + /// - reason: Optional reason for the result. + public func recordPayment( + peerPubkey: String, + peerName: String, + amount: Int64, + approved: Bool, + reason: String = "" + ) { + let entry = AutoPayHistoryEntry( + peerPubkey: peerPubkey, + peerName: peerName, + amount: amount, + wasApproved: approved, + reason: reason + ) + + try? autoPayStorage.saveHistoryEntry(entry) + } +} + diff --git a/Bitkit/PaykitIntegration/Services/DirectoryService.swift b/Bitkit/PaykitIntegration/Services/DirectoryService.swift index 14c2333b..ac679d54 100644 --- a/Bitkit/PaykitIntegration/Services/DirectoryService.swift +++ b/Bitkit/PaykitIntegration/Services/DirectoryService.swift @@ -56,8 +56,8 @@ public final class DirectoryService { private var unauthenticatedTransport: UnauthenticatedTransportFfi? private var authenticatedTransport: AuthenticatedTransportFfi? private var authenticatedAdapter: PubkyAuthenticatedStorageAdapter? - private var homeserverBaseURL: String? - private var ownerPubkey: String? + private var homeserverURL: HomeserverURL? + private var ownerPubkey: OwnerPubkey? private init() { // Create directory operations manager @@ -78,10 +78,15 @@ public final class DirectoryService { } /// Configure Pubky transport for directory operations - /// - Parameter homeserverBaseURL: The homeserver pubkey (defaults to PubkyConfig.defaultHomeserver) - public func configurePubkyTransport(homeserverBaseURL: String? = nil) { - self.homeserverBaseURL = homeserverBaseURL ?? PubkyConfig.defaultHomeserver - let adapter = PubkyUnauthenticatedStorageAdapter(homeserverBaseURL: self.homeserverBaseURL) + /// - Parameter homeserverURL: The homeserver URL (defaults to resolved default homeserver) + public func configurePubkyTransport(homeserverURL: HomeserverURL? = nil) { + let resolvedURL = homeserverURL ?? HomeserverResolver.shared.resolve(pubkey: PubkyConfig.defaultHomeserverPubkey) + guard let resolvedURL else { + Logger.error("Failed to resolve homeserver URL for directory transport", context: "DirectoryService") + return + } + self.homeserverURL = resolvedURL + let adapter = PubkyUnauthenticatedStorageAdapter(homeserverURL: resolvedURL) unauthenticatedTransport = UnauthenticatedTransportFfi.fromCallback(callback: adapter) } @@ -89,26 +94,35 @@ public final class DirectoryService { /// - Parameters: /// - sessionId: The session ID from Pubky-ring /// - ownerPubkey: The owner's public key - /// - homeserverBaseURL: The homeserver pubkey (defaults to PubkyConfig.defaultHomeserver) - public func configureAuthenticatedTransport(sessionId: String, ownerPubkey: String, homeserverBaseURL: String? = nil) { - self.homeserverBaseURL = homeserverBaseURL ?? PubkyConfig.defaultHomeserver + /// - homeserverURL: The homeserver URL (defaults to resolved default homeserver) + public func configureAuthenticatedTransport(sessionId: String, ownerPubkey: OwnerPubkey, homeserverURL: HomeserverURL? = nil) { + let resolvedURL = homeserverURL ?? HomeserverResolver.shared.resolve(pubkey: PubkyConfig.defaultHomeserverPubkey) + guard let resolvedURL else { + Logger.error("Failed to resolve homeserver URL for authenticated transport", context: "DirectoryService") + return + } + self.homeserverURL = resolvedURL self.ownerPubkey = ownerPubkey - let adapter = PubkyAuthenticatedStorageAdapter(sessionId: sessionId, homeserverBaseURL: self.homeserverBaseURL) + let adapter = PubkyAuthenticatedStorageAdapter(sessionId: sessionId, homeserverURL: resolvedURL) self.authenticatedAdapter = adapter - authenticatedTransport = AuthenticatedTransportFfi.fromCallback(callback: adapter, ownerPubkey: ownerPubkey) + authenticatedTransport = AuthenticatedTransportFfi.fromCallback(callback: adapter, ownerPubkey: ownerPubkey.value) } /// Configure transport using a Pubky session from Pubky-ring public func configureWithPubkySession(_ session: PubkySession) { - homeserverBaseURL = PubkyConfig.defaultHomeserver + guard let resolvedURL = HomeserverResolver.shared.resolve(pubkey: PubkyConfig.defaultHomeserverPubkey) else { + Logger.error("Failed to resolve homeserver URL for Pubky session", context: "DirectoryService") + return + } + homeserverURL = resolvedURL // Configure authenticated transport - let adapter = PubkyAuthenticatedStorageAdapter(sessionId: session.sessionSecret, homeserverBaseURL: homeserverBaseURL) - self.ownerPubkey = session.pubkey + let adapter = PubkyAuthenticatedStorageAdapter(sessionId: session.sessionSecret, homeserverURL: resolvedURL) + self.ownerPubkey = OwnerPubkey(session.pubkey) authenticatedTransport = AuthenticatedTransportFfi.fromCallback(callback: adapter, ownerPubkey: session.pubkey) // Also configure unauthenticated transport - let unauthAdapter = PubkyUnauthenticatedStorageAdapter(homeserverBaseURL: homeserverBaseURL) + let unauthAdapter = PubkyUnauthenticatedStorageAdapter(homeserverURL: resolvedURL) unauthenticatedTransport = UnauthenticatedTransportFfi.fromCallback(callback: unauthAdapter) Logger.info("Configured DirectoryService with Pubky session for \(session.pubkey)", context: "DirectoryService") @@ -520,12 +534,17 @@ public final class DirectoryService { /// Publish our push notification endpoint to the directory. /// This allows other users to discover how to wake our device for Noise connections. /// + /// - Warning: DEPRECATED - This publishes tokens publicly, enabling DoS attacks. + /// Use `PushRelayService.register()` instead for secure push token registration. + /// This method will be removed in a future release. + /// /// - Parameters: /// - deviceToken: APNs/FCM device token /// - platform: Platform identifier ("ios" or "android") /// - noiseHost: Host for our Noise server /// - noisePort: Port for our Noise server /// - noisePubkey: Our Noise public key + @available(*, deprecated, message: "Use PushRelayService.register() for secure push registration") public func publishPushNotificationEndpoint( deviceToken: String, platform: String, @@ -558,8 +577,13 @@ public final class DirectoryService { /// Discover push notification endpoint for a recipient. /// Used to send wake notifications before attempting Noise connections. /// + /// - Warning: DEPRECATED - Use `PushRelayService.wake()` instead. + /// Direct discovery exposes tokens publicly. The push relay service + /// handles routing without exposing tokens. + /// /// - Parameter recipientPubkey: The public key of the recipient /// - Returns: Push notification endpoint if found + @available(*, deprecated, message: "Use PushRelayService.wake() for secure wake notifications") public func discoverPushNotificationEndpoint(for recipientPubkey: String) async throws -> PushNotificationEndpoint? { let adapter = unauthenticatedTransport ?? { let adapter = PubkyUnauthenticatedStorageAdapter(homeserverBaseURL: homeserverBaseURL) diff --git a/Bitkit/PaykitIntegration/Services/NoiseKeyCache.swift b/Bitkit/PaykitIntegration/Services/NoiseKeyCache.swift index d6b4314a..7010c4f1 100644 --- a/Bitkit/PaykitIntegration/Services/NoiseKeyCache.swift +++ b/Bitkit/PaykitIntegration/Services/NoiseKeyCache.swift @@ -23,40 +23,41 @@ public final class NoiseKeyCache { } /// Get a cached key if available + /// + /// Thread-safe: Uses barrier sync for atomic read-check-write operation public func getKey(deviceId: String, epoch: UInt32) -> Data? { let key = cacheKey(deviceId: deviceId, epoch: epoch) - // Check memory cache first - var result: Data? - cacheQueue.sync { - result = memoryCache[key] - } - - if let cached = result { - return cached - } - - // Check persistent cache - if let keyData = try? keychain.retrieve(key: key) { - cacheQueue.async(flags: .barrier) { - self.memoryCache[key] = keyData + // Use barrier sync for atomic read-check-write to prevent race conditions + // where multiple threads could simultaneously load from keychain + return cacheQueue.sync(flags: .barrier) { () -> Data? in + // Check memory cache first + if let cached = memoryCache[key] { + return cached + } + + // Check persistent cache (within the same barrier to ensure atomicity) + if let keyData = try? keychain.retrieve(key: key) { + memoryCache[key] = keyData + return keyData } - return keyData + + return nil } - - return nil } /// Store a key in the cache + /// + /// Thread-safe: Uses barrier sync for atomic write public func setKey(_ keyData: Data, deviceId: String, epoch: UInt32) { let key = cacheKey(deviceId: deviceId, epoch: epoch) - // Store in memory cache - cacheQueue.async(flags: .barrier) { + // Store in memory cache with barrier sync to ensure write completes before returning + cacheQueue.sync(flags: .barrier) { self.memoryCache[key] = keyData } - // Store in keychain + // Store in keychain (outside barrier, as keychain has its own thread safety) try? keychain.store(key: key, data: keyData) // Cleanup old epochs if needed @@ -64,8 +65,10 @@ public final class NoiseKeyCache { } /// Clear all cached keys + /// + /// Thread-safe: Uses barrier sync for atomic write public func clearAll() { - cacheQueue.async(flags: .barrier) { + cacheQueue.sync(flags: .barrier) { self.memoryCache.removeAll() } } diff --git a/Bitkit/PaykitIntegration/Services/NoisePaymentService.swift b/Bitkit/PaykitIntegration/Services/NoisePaymentService.swift index 66bbad1e..29cc0af2 100644 --- a/Bitkit/PaykitIntegration/Services/NoisePaymentService.swift +++ b/Bitkit/PaykitIntegration/Services/NoisePaymentService.swift @@ -125,6 +125,7 @@ internal struct NoiseMessage: Codable { /// Service errors public enum NoisePaymentError: LocalizedError { case noIdentity + case noKeypair case keyDerivationFailed(String) case endpointNotFound case invalidEndpoint(String) @@ -141,6 +142,8 @@ public enum NoisePaymentError: LocalizedError { switch self { case .noIdentity: return "No identity configured" + case .noKeypair: + return "No noise keypair available. Please reconnect to Pubky Ring." case .keyDerivationFailed(let msg): return "Failed to derive encryption keys: \(msg)" case .endpointNotFound: @@ -179,6 +182,38 @@ public final class NoisePaymentService { private init() {} /// Initialize with PaykitClient + /// Check if key rotation is needed and perform epoch swap + /// + /// This checks if we should rotate from epoch 0 to epoch 1. + /// Rotation happens automatically when epoch 1 keys are available. + /// + /// - Parameter forceRotation: If true, rotate immediately if epoch 1 is available + /// - Returns: True if rotation occurred + public func checkKeyRotation(forceRotation: Bool = false) -> Bool { + let keyManager = PaykitKeyManager.shared + let currentEpoch = keyManager.getCurrentEpoch() + + // Only rotate from epoch 0 to epoch 1 + guard currentEpoch == 0 else { + return false + } + + // Check if we have epoch 1 keypair available + guard keyManager.getCachedNoiseKeypair(epoch: 1) != nil else { + return false + } + + // For now, rotation is manual via forceRotation parameter + // In production, this would check time-based thresholds or external signals + if forceRotation { + keyManager.setCurrentEpoch(1) + Logger.info("Rotated to epoch 1 keypair", context: "NoisePaymentService") + return true + } + + return false + } + public func initialize(client: PaykitClient) { self.paykitClient = client } @@ -186,8 +221,14 @@ public final class NoisePaymentService { private func getNoiseManager(isServer: Bool) throws -> FfiNoiseManager { if let existing = noiseManager { return existing } - guard let seedData = PaykitKeyManager.shared.getSecretKeyBytes() else { - throw NoisePaymentError.noIdentity + // Get cached X25519 keypair from Ring (no local Ed25519 derivation) + guard let keypair = PaykitKeyManager.shared.getCachedNoiseKeypair() else { + throw NoisePaymentError.noKeypair + } + + // Use X25519 secret key as seed for Noise manager + guard let seedData = Data(hex: keypair.secretKeyHex) else { + throw NoisePaymentError.noKeypair } let deviceId = PaykitKeyManager.shared.getDeviceId() diff --git a/Bitkit/PaykitIntegration/Services/PaykitNetworkConfig.swift b/Bitkit/PaykitIntegration/Services/PaykitNetworkConfig.swift new file mode 100644 index 00000000..c369e4a2 --- /dev/null +++ b/Bitkit/PaykitIntegration/Services/PaykitNetworkConfig.swift @@ -0,0 +1,109 @@ +// +// PaykitNetworkConfig.swift +// Bitkit +// +// Shared URLSession configuration for Paykit network operations. +// Centralizes timeout, caching, and security settings. +// + +import Foundation + +/// Shared network configuration for Paykit operations. +/// +/// Provides a pre-configured URLSession with appropriate timeouts and security settings +/// for Paykit network operations. Use `PaykitNetworkConfig.shared.session` instead of +/// creating individual URLSession instances. +/// +/// ## Usage +/// +/// ```swift +/// let session = PaykitNetworkConfig.shared.session +/// let (data, response) = try await session.data(from: url) +/// ``` +public final class PaykitNetworkConfig { + + // MARK: - Singleton + + public static let shared = PaykitNetworkConfig() + + // MARK: - Configuration Constants + + /// Timeout for individual requests (30 seconds) + public static let defaultRequestTimeout: TimeInterval = 30.0 + + /// Timeout for resource loading (60 seconds) + public static let defaultResourceTimeout: TimeInterval = 60.0 + + /// Maximum concurrent connections per host + public static let maxConnectionsPerHost = 4 + + // MARK: - Properties + + /// Shared URLSession for Paykit network operations. + /// + /// Pre-configured with: + /// - 30 second request timeout + /// - 60 second resource timeout + /// - HTTP cookie storage for session management + /// - Caching disabled for sensitive payment data + public let session: URLSession + + /// The underlying configuration (exposed for testing) + public let configuration: URLSessionConfiguration + + // MARK: - Initialization + + private init() { + let config = URLSessionConfiguration.default + + // Timeouts + config.timeoutIntervalForRequest = Self.defaultRequestTimeout + config.timeoutIntervalForResource = Self.defaultResourceTimeout + + // Connection settings + config.httpMaximumConnectionsPerHost = Self.maxConnectionsPerHost + + // Cookie storage for session management + config.httpCookieStorage = HTTPCookieStorage.shared + config.httpCookieAcceptPolicy = .always + + // Disable URL cache for sensitive payment data + config.urlCache = nil + config.requestCachePolicy = .reloadIgnoringLocalCacheData + + // Enable HTTP/2 when available + config.httpAdditionalHeaders = [ + "Accept": "application/json", + "User-Agent": "Bitkit-iOS/1.0 Paykit/1.0", + ] + + self.configuration = config + self.session = URLSession(configuration: config) + } + + // MARK: - Factory Methods + + /// Create a session with custom timeout. + /// + /// - Parameter timeout: Request timeout in seconds. + /// - Returns: A new URLSession with the custom timeout. + public func sessionWithTimeout(_ timeout: TimeInterval) -> URLSession { + let config = configuration.copy() as! URLSessionConfiguration + config.timeoutIntervalForRequest = timeout + return URLSession(configuration: config) + } + + /// Create a session for background transfers. + /// + /// - Parameter identifier: Background session identifier. + /// - Returns: A background URLSession. + public func backgroundSession(identifier: String) -> URLSession { + let config = URLSessionConfiguration.background(withIdentifier: identifier) + config.timeoutIntervalForRequest = Self.defaultRequestTimeout + config.timeoutIntervalForResource = Self.defaultResourceTimeout * 2 + config.isDiscretionary = false + config.sessionSendsLaunchEvents = true + return URLSession(configuration: config) + } +} + diff --git a/Bitkit/PaykitIntegration/Services/PaykitReceiptStore.swift b/Bitkit/PaykitIntegration/Services/PaykitReceiptStore.swift index 791b3040..7d0af451 100644 --- a/Bitkit/PaykitIntegration/Services/PaykitReceiptStore.swift +++ b/Bitkit/PaykitIntegration/Services/PaykitReceiptStore.swift @@ -1,34 +1,37 @@ // PaykitReceiptStore.swift // Bitkit iOS - Paykit Integration // -// Persistent receipt storage using UserDefaults. +// Persistent receipt storage using Keychain for secure storage. import Foundation // MARK: - PaykitReceiptStore -/// Persistent receipt store using UserDefaults. +/// Persistent receipt store using Keychain for secure storage. /// /// Provides thread-safe storage and retrieval of payment receipts. -/// Receipts are automatically persisted to disk and survive app restarts. +/// Receipts are automatically persisted to Keychain and survive app restarts. +/// +/// Security: Uses PaykitKeychainStorage to ensure receipts (which contain +/// payment amounts and peer pubkeys) are encrypted at rest. public final class PaykitReceiptStore { // MARK: - Constants - private static let storageKey = "com.bitkit.paykit.receipts" + private static let storageKey = "paykit.receipts" private static let maxReceipts = 1000 // Prevent unbounded growth // MARK: - Properties - private let defaults: UserDefaults + private let keychain: PaykitKeychainStorage private var cache: [String: PaykitReceipt] = [:] private let queue = DispatchQueue(label: "PaykitReceiptStore", attributes: .concurrent) private var isLoaded = false // MARK: - Initialization - public init(defaults: UserDefaults = .standard) { - self.defaults = defaults + public init(keychain: PaykitKeychainStorage = .shared) { + self.keychain = keychain loadFromDisk() } @@ -93,7 +96,7 @@ public final class PaykitReceiptStore { public func clear() { queue.async(flags: .barrier) { self.cache.removeAll() - self.defaults.removeObject(forKey: Self.storageKey) + self.keychain.deleteQuietly(key: Self.storageKey) } } @@ -108,13 +111,13 @@ public final class PaykitReceiptStore { queue.async(flags: .barrier) { guard !self.isLoaded else { return } - if let data = self.defaults.data(forKey: Self.storageKey) { + if let data = self.keychain.get(key: Self.storageKey) { do { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 let receipts = try decoder.decode([PaykitReceipt].self, from: data) self.cache = Dictionary(uniqueKeysWithValues: receipts.map { ($0.id, $0) }) - Logger.debug("Loaded \(receipts.count) receipts from disk", context: "PaykitReceiptStore") + Logger.debug("Loaded \(receipts.count) receipts from keychain", context: "PaykitReceiptStore") } catch { Logger.error("Failed to load receipts: \(error)", context: "PaykitReceiptStore") } @@ -137,8 +140,8 @@ public final class PaykitReceiptStore { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 let data = try encoder.encode(Array(cache.values)) - defaults.set(data, forKey: Self.storageKey) - Logger.debug("Saved \(cache.count) receipts to disk", context: "PaykitReceiptStore") + keychain.set(key: Self.storageKey, value: data) + Logger.debug("Saved \(cache.count) receipts to keychain", context: "PaykitReceiptStore") } catch { Logger.error("Failed to save receipts: \(error)", context: "PaykitReceiptStore") } diff --git a/Bitkit/PaykitIntegration/Services/PubkyRingBridge.swift b/Bitkit/PaykitIntegration/Services/PubkyRingBridge.swift index 08b5dc00..2af7e221 100644 --- a/Bitkit/PaykitIntegration/Services/PubkyRingBridge.swift +++ b/Bitkit/PaykitIntegration/Services/PubkyRingBridge.swift @@ -68,6 +68,7 @@ public final class PubkyRingBridge { public static let follows = "paykit-follows" public static let crossDeviceSession = "paykit-cross-session" public static let paykitSetup = "paykit-setup" // Combined session + noise keys + public static let signatureResult = "signature-result" // Ed25519 signature result } // MARK: - State @@ -84,15 +85,69 @@ public final class PubkyRingBridge { /// Pending cross-device request ID private var pendingCrossDeviceRequestId: String? - /// Cached sessions by pubkey + /// Lock for thread-safe cache access + private let cacheLock = NSLock() + + /// Cached sessions by pubkey - access via thread-safe methods private var sessionCache: [String: PubkySession] = [:] - /// Cached keypairs by derivation path + /// Cached keypairs by derivation path - access via thread-safe methods private var keypairCache: [String: NoiseKeypair] = [:] /// Keychain storage for persistent session storage private let keychainStorage = PaykitKeychainStorage() + // MARK: - Thread-Safe Cache Helpers + + private func withCacheLock(_ operation: () -> T) -> T { + cacheLock.lock() + defer { cacheLock.unlock() } + return operation() + } + + private func getSession(_ pubkey: String) -> PubkySession? { + withCacheLock { sessionCache[pubkey] } + } + + private func setSession(_ session: PubkySession) { + withCacheLock { sessionCache[session.pubkey] = session } + } + + private func getKeypair(_ cacheKey: String) -> NoiseKeypair? { + withCacheLock { keypairCache[cacheKey] } + } + + private func setKeypair(_ keypair: NoiseKeypair, cacheKey: String) { + withCacheLock { keypairCache[cacheKey] = keypair } + } + + private func clearAllCaches() { + withCacheLock { + sessionCache.removeAll() + keypairCache.removeAll() + } + } + + private func removeSession(_ pubkey: String) { + withCacheLock { sessionCache.removeValue(forKey: pubkey) } + } + + private func getAllSessions() -> [PubkySession] { + withCacheLock { Array(sessionCache.values) } + } + + private func getSessionCount() -> Int { + withCacheLock { sessionCache.count } + } + + private func getKeypairCount() -> Int { + withCacheLock { keypairCache.count } + } + + private func getAllSessionKeys() -> [String] { + withCacheLock { Array(sessionCache.keys) } + } + /// Device ID for noise key derivation private var _deviceId: String? @@ -193,6 +248,10 @@ public final class PubkyRingBridge { /// - Gets everything in a single user interaction /// - Ensures noise keys are available even if Ring is later unavailable /// - Includes both epoch 0 and epoch 1 keypairs for key rotation + /// - Always uses secure handoff (no secrets in URL) + /// + /// Ring stores secrets on homeserver at an unguessable path and returns only + /// a request_id. Bitkit fetches the payload and deletes it immediately. /// /// - Returns: PaykitSetupResult containing session and noise keypairs /// - Throws: PubkyRingError if request fails or app not installed @@ -203,13 +262,18 @@ public final class PubkyRingBridge { let actualDeviceId = self.deviceId let callbackUrl = "\(bitkitScheme)://\(CallbackPaths.paykitSetup)" - let requestUrl = "\(pubkyRingScheme)://paykit-connect?deviceId=\(actualDeviceId)&callback=\(callbackUrl.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? callbackUrl)" + let encodedCallback = callbackUrl.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? callbackUrl + + let requestUrl = "\(pubkyRingScheme)://paykit-connect?deviceId=\(actualDeviceId)&callback=\(encodedCallback)" + + // Ring always uses secure handoff: secrets stored on homeserver at unguessable path, + // returns only request_id in callback. Bitkit fetches payload and deletes it immediately. guard let url = URL(string: requestUrl) else { throw PubkyRingError.invalidUrl } - Logger.info("Requesting Paykit setup from Pubky Ring", context: "PubkyRingBridge") + Logger.info("Requesting Paykit setup from Pubky Ring (secure handoff)", context: "PubkyRingBridge") let result = try await withCheckedThrowingContinuation { continuation in self.pendingPaykitSetupContinuation = continuation @@ -225,14 +289,14 @@ public final class PubkyRingBridge { } // Cache session - sessionCache[result.session.pubkey] = result.session + setSession(result.session) // Cache and persist noise keypairs let noiseKeyCache = NoiseKeyCache.shared if let keypair0 = result.noiseKeypair0 { let cacheKey = "\(keypair0.deviceId):\(keypair0.epoch)" - keypairCache[cacheKey] = keypair0 + setKeypair(keypair0, cacheKey: cacheKey) if let secretKeyData = keypair0.secretKey.data(using: .utf8) { noiseKeyCache.setKey(secretKeyData, deviceId: keypair0.deviceId, epoch: UInt32(keypair0.epoch)) } @@ -241,7 +305,7 @@ public final class PubkyRingBridge { if let keypair1 = result.noiseKeypair1 { let cacheKey = "\(keypair1.deviceId):\(keypair1.epoch)" - keypairCache[cacheKey] = keypair1 + setKeypair(keypair1, cacheKey: cacheKey) if let secretKeyData = keypair1.secretKey.data(using: .utf8) { noiseKeyCache.setKey(secretKeyData, deviceId: keypair1.deviceId, epoch: UInt32(keypair1.epoch)) } @@ -267,7 +331,7 @@ public final class PubkyRingBridge { let cacheKey = "\(actualDeviceId):\(epoch)" // Check memory cache first - if let cached = keypairCache[cacheKey] { + if let cached = getKeypair(cacheKey) { Logger.debug("Noise keypair cache hit for \(cacheKey)", context: "PubkyRingBridge") return cached } @@ -307,7 +371,7 @@ public final class PubkyRingBridge { } // Cache the keypair - keypairCache[cacheKey] = keypair + setKeypair(keypair, cacheKey: cacheKey) // Persist secret key to NoiseKeyCache if let secretKeyData = keypair.secretKey.data(using: .utf8) { @@ -319,13 +383,53 @@ public final class PubkyRingBridge { /// Get cached session for a pubkey public func getCachedSession(for pubkey: String) -> PubkySession? { - sessionCache[pubkey] + getSession(pubkey) } /// Clear all cached data public func clearCache() { - sessionCache.removeAll() - keypairCache.removeAll() + clearAllCaches() + } + + // MARK: - Ed25519 Signing + + /// Pending signature request continuation + private var pendingSignatureContinuation: CheckedContinuation? + + /// Request an Ed25519 signature from Pubky-ring + /// + /// Ring signs the message with the user's Ed25519 secret key. + /// Used for authenticating requests to external services (e.g., push relay). + /// + /// - Parameter message: The message to sign (UTF-8 string) + /// - Returns: Hex-encoded Ed25519 signature + /// - Throws: PubkyRingError if request fails or app not installed + public func requestSignature(message: String) async throws -> String { + guard isPubkyRingInstalled else { + throw PubkyRingError.appNotInstalled + } + + let callbackUrl = "\(bitkitScheme)://\(CallbackPaths.signatureResult)" + let encodedMessage = message.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? message + let encodedCallback = callbackUrl.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? callbackUrl + let requestUrl = "\(pubkyRingScheme)://sign-message?message=\(encodedMessage)&callback=\(encodedCallback)" + + guard let url = URL(string: requestUrl) else { + throw PubkyRingError.invalidUrl + } + + return try await withCheckedThrowingContinuation { continuation in + pendingSignatureContinuation = continuation + + DispatchQueue.main.async { + UIApplication.shared.open(url) { success in + if !success { + self.pendingSignatureContinuation?.resume(throwing: PubkyRingError.failedToOpenApp) + self.pendingSignatureContinuation = nil + } + } + } + } } // MARK: - Profile & Follows Requests @@ -449,13 +553,13 @@ public final class PubkyRingBridge { while Date().timeIntervalSince(startTime) < timeout { // Check if session arrived via direct callback - if let session = sessionCache.values.first(where: { _ in pendingCrossDeviceRequestId == nil }) { + if pendingCrossDeviceRequestId == nil, let session = getAllSessions().first { return session } // Poll relay for session if let session = try? await pollRelayForSession(requestId: requestId) { - sessionCache[session.pubkey] = session + setSession(session) pendingCrossDeviceRequestId = nil return session } @@ -483,7 +587,7 @@ public final class PubkyRingBridge { capabilities: capabilities, createdAt: Date() ) - sessionCache[pubkey] = session + setSession(session) return session } @@ -594,6 +698,8 @@ public final class PubkyRingBridge { return handleCrossDeviceSessionCallback(url: url) case CallbackPaths.paykitSetup: return handlePaykitSetupCallback(url: url) + case CallbackPaths.signatureResult: + return handleSignatureCallback(url: url) default: return false } @@ -633,7 +739,7 @@ public final class PubkyRingBridge { ) // Cache the session - sessionCache[pubkey] = session + setSession(session) // Persist to keychain persistSession(session) @@ -678,7 +784,7 @@ public final class PubkyRingBridge { // Cache the keypair in memory let cacheKey = "\(deviceId):\(epoch)" - keypairCache[cacheKey] = keypair + setKeypair(keypair, cacheKey: cacheKey) // Persist secret key to NoiseKeyCache if let secretKeyData = secretKey.data(using: .utf8) { @@ -707,7 +813,29 @@ public final class PubkyRingBridge { } } - // Required session parameters + // Check for secure handoff mode + if params["mode"] == "secure_handoff" { + guard let pubkey = params["pubky"], + let requestId = params["request_id"] else { + pendingPaykitSetupContinuation?.resume(throwing: PubkyRingError.missingParameters) + pendingPaykitSetupContinuation = nil + return true + } + + // Fetch payload from homeserver asynchronously + Task { + do { + let result = try await fetchSecureHandoffPayload(pubkey: pubkey, requestId: requestId) + pendingPaykitSetupContinuation?.resume(returning: result) + } catch { + pendingPaykitSetupContinuation?.resume(throwing: error) + } + pendingPaykitSetupContinuation = nil + } + return true + } + + // Legacy mode: secrets in URL guard let pubkey = params["pubky"], let sessionSecret = params["session_secret"], let deviceId = params["device_id"] else { @@ -763,20 +891,20 @@ public final class PubkyRingBridge { // Always cache the session directly for robustness // This ensures session is available even if called outside of requestPaykitSetup flow - sessionCache[session.pubkey] = session + setSession(session) persistSession(session) // Cache noise keypairs if present if let kp0 = keypair0 { let cacheKey0 = "\(kp0.deviceId):\(kp0.epoch)" - keypairCache[cacheKey0] = kp0 + setKeypair(kp0, cacheKey: cacheKey0) if let secretKeyData = kp0.secretKey.data(using: .utf8) { NoiseKeyCache.shared.setKey(secretKeyData, deviceId: kp0.deviceId, epoch: UInt32(kp0.epoch)) } } if let kp1 = keypair1 { let cacheKey1 = "\(kp1.deviceId):\(kp1.epoch)" - keypairCache[cacheKey1] = kp1 + setKeypair(kp1, cacheKey: cacheKey1) if let secretKeyData = kp1.secretKey.data(using: .utf8) { NoiseKeyCache.shared.setKey(secretKeyData, deviceId: kp1.deviceId, epoch: UInt32(kp1.epoch)) } @@ -788,6 +916,97 @@ public final class PubkyRingBridge { return true } + /// Fetch secure handoff payload from homeserver + private func fetchSecureHandoffPayload(pubkey: String, requestId: String) async throws -> PaykitSetupResult { + let handoffUri = "pubky://\(pubkey)/pub/paykit.app/v0/handoff/\(requestId)" + + Logger.info("Fetching secure handoff payload from \(handoffUri.prefix(50))...", context: "PubkyRingBridge") + + // Fetch payload using PubkySDKService + let data = try await PubkySDKService.shared.publicGet(uri: handoffUri) + + // Parse JSON payload + guard let payload = try? JSONDecoder().decode(SecureHandoffPayload.self, from: data) else { + throw PubkyRingError.invalidCallback + } + + // Validate payload hasn't expired + if Date().timeIntervalSince1970 * 1000 > Double(payload.expiresAt) { + throw PubkyRingError.timeout + } + + // Build session + let session = PubkySession( + pubkey: payload.pubky, + sessionSecret: payload.sessionSecret, + capabilities: payload.capabilities, + createdAt: Date(timeIntervalSince1970: Double(payload.createdAt) / 1000), + expiresAt: nil + ) + + // Build noise keypairs + var keypair0: NoiseKeypair? = nil + var keypair1: NoiseKeypair? = nil + + for kp in payload.noiseKeypairs { + let keypair = NoiseKeypair( + publicKey: kp.publicKey, + secretKey: kp.secretKey, + deviceId: payload.deviceId, + epoch: UInt64(kp.epoch) + ) + + if kp.epoch == 0 { + keypair0 = keypair + } else if kp.epoch == 1 { + keypair1 = keypair + } + } + + let result = PaykitSetupResult( + session: session, + deviceId: payload.deviceId, + noiseKeypair0: keypair0, + noiseKeypair1: keypair1 + ) + + Logger.info("Secure handoff payload received for \(payload.pubky.prefix(12))...", context: "PubkyRingBridge") + + // Cache session and keypairs + setSession(session) + persistSession(session) + + if let kp0 = keypair0 { + let cacheKey0 = "\(kp0.deviceId):\(kp0.epoch)" + setKeypair(kp0, cacheKey: cacheKey0) + if let secretKeyData = kp0.secretKey.data(using: .utf8) { + NoiseKeyCache.shared.setKey(secretKeyData, deviceId: kp0.deviceId, epoch: UInt32(kp0.epoch)) + } + } + if let kp1 = keypair1 { + let cacheKey1 = "\(kp1.deviceId):\(kp1.epoch)" + setKeypair(kp1, cacheKey: cacheKey1) + if let secretKeyData = kp1.secretKey.data(using: .utf8) { + NoiseKeyCache.shared.setKey(secretKeyData, deviceId: kp1.deviceId, epoch: UInt32(kp1.epoch)) + } + } + + // Delete handoff file from homeserver to minimize attack window + Task { + do { + let handoffPath = "/pub/paykit.app/v0/handoff/\(requestId)" + let adapter = PubkyAuthenticatedStorageAdapter(sessionId: session.sessionSecret, homeserverURL: PubkyConfig.defaultHomeserverURL) + let transport = AuthenticatedTransportFfi.fromCallback(callback: adapter, ownerPubkey: session.pubkey) + try await PubkyStorageAdapter.shared.deleteFile(path: handoffPath, transport: transport) + Logger.info("Deleted secure handoff payload: \(requestId)", context: "PubkyRingBridge") + } catch { + Logger.warn("Failed to delete handoff payload: \(error)", context: "PubkyRingBridge") + } + } + + return result + } + private func handleProfileCallback(url: URL) -> Bool { guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), let queryItems = components.queryItems else { @@ -864,6 +1083,44 @@ public final class PubkyRingBridge { return true } + private func handleSignatureCallback(url: URL) -> Bool { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let queryItems = components.queryItems else { + pendingSignatureContinuation?.resume(throwing: PubkyRingError.invalidCallback) + pendingSignatureContinuation = nil + return true + } + + var params: [String: String] = [:] + for item in queryItems { + if let value = item.value { + params[item.name] = value + } + } + + // Check for error response + if let error = params["error"] { + Logger.warn("Signature request returned error: \(error)", context: "PubkyRingBridge") + pendingSignatureContinuation?.resume(throwing: PubkyRingError.signatureFailed) + pendingSignatureContinuation = nil + return true + } + + // Get signature from response + guard let signature = params["signature"] else { + pendingSignatureContinuation?.resume(throwing: PubkyRingError.invalidCallback) + pendingSignatureContinuation = nil + return true + } + + Logger.debug("Received Ed25519 signature from Pubky-ring", context: "PubkyRingBridge") + + pendingSignatureContinuation?.resume(returning: signature) + pendingSignatureContinuation = nil + + return true + } + private func handleCrossDeviceSessionCallback(url: URL) -> Bool { guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), let queryItems = components.queryItems else { @@ -898,7 +1155,7 @@ public final class PubkyRingBridge { ) // Cache the session - sessionCache[pubkey] = session + setSession(session) pendingCrossDeviceRequestId = nil // Persist to keychain for cross-device sessions too @@ -928,50 +1185,51 @@ public final class PubkyRingBridge { do { guard let data = keychainStorage.get(key: key) else { continue } let session = try JSONDecoder().decode(PubkySession.self, from: data) - sessionCache[session.pubkey] = session + setSession(session) Logger.info("Restored session for \(session.pubkey.prefix(12))...", context: "PubkyRingBridge") } catch { Logger.error("Failed to restore session from \(key): \(error)", context: "PubkyRingBridge") } } - Logger.info("Restored \(sessionCache.count) sessions from keychain", context: "PubkyRingBridge") + Logger.info("Restored \(getSessionCount()) sessions from keychain", context: "PubkyRingBridge") } /// Get all cached sessions public var cachedSessions: [PubkySession] { - Array(sessionCache.values) + getAllSessions() } /// Get all cached sessions - public func getAllSessions() -> [PubkySession] { - Array(sessionCache.values) + public func getAllSessionsList() -> [PubkySession] { + getAllSessions() } /// Get count of cached keypairs public func getCachedKeypairCount() -> Int { - keypairCache.count + getKeypairCount() } /// Clear a specific session from cache and keychain public func clearSession(pubkey: String) { - sessionCache.removeValue(forKey: pubkey) + removeSession(pubkey) keychainStorage.deleteQuietly(key: "pubky.session.\(pubkey)") Logger.info("Cleared session for \(pubkey.prefix(12))...", context: "PubkyRingBridge") } /// Clear all sessions from cache and keychain public func clearAllSessions() { - for pubkey in sessionCache.keys { + let pubkeys = getAllSessionKeys() + for pubkey in pubkeys { keychainStorage.deleteQuietly(key: "pubky.session.\(pubkey)") } - sessionCache.removeAll() + clearAllCaches() Logger.info("Cleared all sessions", context: "PubkyRingBridge") } /// Set a session directly (for manual or imported sessions) public func setCachedSession(_ session: PubkySession) { - sessionCache[session.pubkey] = session + setSession(session) persistSession(session) } @@ -1011,11 +1269,12 @@ public final class PubkyRingBridge { /// /// - Returns: BackupData containing device ID, sessions, and noise keys public func exportBackup() -> BackupData { - let sessions = Array(sessionCache.values) + let sessions = getAllSessions() var noiseKeys: [BackupNoiseKey] = [] - // Export noise keys from keypair cache - for (cacheKey, keypair) in keypairCache { + // Export noise keys from keypair cache (thread-safe copy) + let keypairs = withCacheLock { Array(keypairCache.values) } + for keypair in keypairs { noiseKeys.append(BackupNoiseKey( deviceId: keypair.deviceId, epoch: keypair.epoch, @@ -1059,7 +1318,7 @@ public final class PubkyRingBridge { // Restore sessions for session in backup.sessions { - sessionCache[session.pubkey] = session + setSession(session) persistSession(session) } @@ -1126,6 +1385,41 @@ public struct NoiseKeypair: Codable { public let epoch: UInt64 } +/// Secure handoff payload structure (stored on homeserver by Ring) +private struct SecureHandoffPayload: Codable { + let version: Int + let pubky: String + let sessionSecret: String + let capabilities: [String] + let deviceId: String + let noiseKeypairs: [SecureHandoffNoiseKeypair] + let createdAt: Int64 + let expiresAt: Int64 + + enum CodingKeys: String, CodingKey { + case version + case pubky + case sessionSecret = "session_secret" + case capabilities + case deviceId = "device_id" + case noiseKeypairs = "noise_keypairs" + case createdAt = "created_at" + case expiresAt = "expires_at" + } +} + +private struct SecureHandoffNoiseKeypair: Codable { + let epoch: Int + let publicKey: String + let secretKey: String + + enum CodingKeys: String, CodingKey { + case epoch + case publicKey = "public_key" + case secretKey = "secret_key" + } +} + /// Result from combined Paykit setup request /// Contains everything needed to operate Paykit: session + noise keys public struct PaykitSetupResult { @@ -1157,6 +1451,7 @@ public enum PubkyRingError: LocalizedError { case missingParameters case timeout case cancelled + case signatureFailed case crossDeviceFailed(String) public var errorDescription: String? { @@ -1175,6 +1470,8 @@ public enum PubkyRingError: LocalizedError { return "Request to Pubky-ring timed out" case .cancelled: return "Request was cancelled" + case .signatureFailed: + return "Failed to generate signature" case .crossDeviceFailed(let reason): return "Cross-device authentication failed: \(reason)" } @@ -1193,6 +1490,8 @@ public enum PubkyRingError: LocalizedError { return "The request timed out. Please try again." case .cancelled: return "Authentication was cancelled." + case .signatureFailed: + return "Failed to sign the message. Please try again." case .crossDeviceFailed: return "Cross-device authentication failed. Please try again." } diff --git a/Bitkit/PaykitIntegration/Services/PubkyRingIntegration.swift b/Bitkit/PaykitIntegration/Services/PubkyRingIntegration.swift index e65de04b..6a9f02a3 100644 --- a/Bitkit/PaykitIntegration/Services/PubkyRingIntegration.swift +++ b/Bitkit/PaykitIntegration/Services/PubkyRingIntegration.swift @@ -2,15 +2,18 @@ // PubkyRingIntegration.swift // Bitkit // -// Pubky Ring Integration for key derivation -// Uses PaykitMobile FFI to derive X25519 keys from Ed25519 identity +// Pubky Ring Integration for X25519 noise keypair retrieval +// X25519 keys are derived by Ring and cached locally - no local derivation // import Foundation // PaykitMobile types are available from FFI/PaykitMobile.swift -/// Integration for X25519 key derivation from Ed25519 identity -/// Uses PaykitMobile FFI to derive keys deterministically from identity seed +/// Integration for X25519 keypair retrieval from Pubky Ring +/// +/// SECURITY: All key derivation happens in Pubky Ring. +/// This class only retrieves cached keypairs that were received via Ring callbacks. +/// If no cached keypair is available, callers must request new keys from Ring. public final class PubkyRingIntegration { public static let shared = PubkyRingIntegration() @@ -23,48 +26,117 @@ public final class PubkyRingIntegration { self.noiseKeyCache = NoiseKeyCache.shared } - /// Get or derive X25519 keypair with caching - /// This method first checks the NoiseKeyCache, then requests from - /// PaykitMobile FFI if not cached. - public func getOrDeriveKeypair(deviceId: String, epoch: UInt32) async throws -> X25519Keypair { - // Check cache first - if let cached = noiseKeyCache.getKey(deviceId: deviceId, epoch: epoch) { - // Reconstruct keypair from cached secret - // Note: We need the public key, so we'll derive again - // In production, cache could store full keypair + /// Get cached X25519 keypair for the given epoch + /// + /// This retrieves a keypair that was previously received from Pubky Ring. + /// If no keypair is cached, the caller should request new keys via PubkyRingBridge. + /// + /// - Parameters: + /// - deviceId: The device ID used for derivation context + /// - epoch: The epoch for this keypair + /// - Returns: The cached keypair + /// - Throws: PaykitRingError.noKeypairCached if no keypair is available + public func getCachedKeypair(deviceId: String, epoch: UInt32) throws -> X25519Keypair { + // First check NoiseKeyCache (legacy cache) + if let cachedSecret = noiseKeyCache.getKey(deviceId: deviceId, epoch: epoch) { + // We have a cached secret but need the full keypair + // Check KeyManager for full keypair + if let keypair = keyManager.getCachedNoiseKeypair(epoch: epoch) { + return keypair + } } - // Derive via PaykitMobile FFI - guard let ed25519SecretHex = keyManager.getSecretKeyHex() else { - throw PaykitRingError.noIdentity("No Ed25519 identity configured in Bitkit.") + // Check KeyManager directly + if let keypair = keyManager.getCachedNoiseKeypair(epoch: epoch) { + return keypair } - let keypair = try deriveX25519Keypair( - ed25519SecretHex: ed25519SecretHex, - deviceId: deviceId, - epoch: epoch + throw PaykitRingError.noKeypairCached( + "No X25519 keypair cached for epoch \(epoch). Please reconnect to Pubky Ring." ) + } + + /// Get the current noise keypair (for current epoch) + /// - Returns: The cached keypair for current epoch + /// - Throws: PaykitRingError.noKeypairCached if no keypair is available + public func getCurrentKeypair() throws -> X25519Keypair { + let deviceId = keyManager.getDeviceId() + let epoch = keyManager.getCurrentEpoch() + return try getCachedKeypair(deviceId: deviceId, epoch: epoch) + } + + /// Check if we have a cached keypair for the current epoch + public var hasCurrentKeypair: Bool { + return keyManager.hasNoiseKeypair + } + + /// Get or refresh X25519 keypair with automatic cache miss recovery + /// + /// If the keypair is cached, returns it immediately. + /// If not cached, automatically requests new setup from Ring. + /// + /// - Parameters: + /// - deviceId: The device ID used for derivation context + /// - epoch: The epoch for this keypair + /// - Returns: The keypair (either cached or freshly retrieved) + /// - Throws: PubkyRingError if Ring request fails + public func getOrRefreshKeypair(deviceId: String, epoch: UInt32) async throws -> X25519Keypair { + // Try cache first + if let cached = try? getCachedKeypair(deviceId: deviceId, epoch: epoch) { + return cached + } + + // Cache miss - request new setup from Ring + Logger.warn("Keypair cache miss for epoch \(epoch), requesting from Ring", context: "PubkyRingIntegration") + let result = try await PubkyRingBridge.shared.requestPaykitSetup() + + // The bridge callback handler will have cached the result + // Try retrieving again + if let cached = try? getCachedKeypair(deviceId: deviceId, epoch: epoch) { + return cached + } - // Cache the secret key bytes + // Still not available - this shouldn't happen + throw PaykitRingError.noKeypairCached( + "Failed to refresh keypair from Ring for epoch \(epoch)" + ) + } + + /// Get the current keypair with automatic refresh on cache miss + /// - Returns: The cached or refreshed keypair for current epoch + /// - Throws: PubkyRingError if Ring request fails + public func getCurrentKeypairOrRefresh() async throws -> X25519Keypair { + let deviceId = keyManager.getDeviceId() + let epoch = keyManager.getCurrentEpoch() + return try await getOrRefreshKeypair(deviceId: deviceId, epoch: epoch) + } + + /// Cache a keypair received from Pubky Ring + /// Called by PubkyRingBridge when receiving keypairs via callback + public func cacheKeypair(_ keypair: X25519Keypair, deviceId: String, epoch: UInt32) throws { + // Store in KeyManager (primary cache) + try keyManager.cacheNoiseKeypair(keypair, epoch: epoch) + + // Also store secret in NoiseKeyCache for backward compatibility if let secretBytes = Data(hex: keypair.secretKeyHex) { noiseKeyCache.setKey(secretBytes, deviceId: deviceId, epoch: epoch) } - - return keypair } } enum PaykitRingError: LocalizedError { case noIdentity(String) + case noKeypairCached(String) case derivationFailed(String) var errorDescription: String? { switch self { case .noIdentity(let msg): return msg + case .noKeypairCached(let msg): + return msg case .derivationFailed(let msg): return "Failed to derive X25519 keypair: \(msg)" } } } - diff --git a/Bitkit/PaykitIntegration/Services/PubkyStorageAdapter.swift b/Bitkit/PaykitIntegration/Services/PubkyStorageAdapter.swift index 26250cf6..0a158730 100644 --- a/Bitkit/PaykitIntegration/Services/PubkyStorageAdapter.swift +++ b/Bitkit/PaykitIntegration/Services/PubkyStorageAdapter.swift @@ -18,10 +18,8 @@ public final class PubkyStorageAdapter { private let session: URLSession private init() { - let config = URLSessionConfiguration.default - config.timeoutIntervalForRequest = 30 - config.timeoutIntervalForResource = 60 - self.session = URLSession(configuration: config) + // Use shared network configuration for consistent timeouts and settings + self.session = PaykitNetworkConfig.shared.session } /// Store data in Pubky storage (requires authenticated adapter) @@ -151,20 +149,18 @@ enum PubkyStorageError: LocalizedError { /// Makes HTTP requests to Pubky homeservers to read public data public class PubkyUnauthenticatedStorageAdapter: PubkyUnauthenticatedStorageCallback { - private let homeserverBaseURL: String? + private let homeserverURL: HomeserverURL? private let session: URLSession - public init(homeserverBaseURL: String? = nil) { - self.homeserverBaseURL = homeserverBaseURL - let config = URLSessionConfiguration.default - config.timeoutIntervalForRequest = 30 - config.timeoutIntervalForResource = 60 - self.session = URLSession(configuration: config) + public init(homeserverURL: HomeserverURL? = nil) { + self.homeserverURL = homeserverURL + // Use shared network configuration for consistent timeouts and settings + self.session = PaykitNetworkConfig.shared.session } public func get(ownerPubkey: String, path: String) -> StorageGetResult { - let urlString = if let baseURL = homeserverBaseURL { - "\(baseURL)/pubky\(ownerPubkey)\(path)" + let urlString = if let url = homeserverURL { + "\(url.value)/pubky\(ownerPubkey)\(path)" } else { "https://_pubky.\(ownerPubkey)\(path)" } @@ -207,8 +203,8 @@ public class PubkyUnauthenticatedStorageAdapter: PubkyUnauthenticatedStorageCall } public func list(ownerPubkey: String, prefix: String) -> StorageListResult { - let urlString = if let baseURL = homeserverBaseURL { - "\(baseURL)/pubky\(ownerPubkey)\(prefix)?shallow=true" + let urlString = if let url = homeserverURL { + "\(url.value)/pubky\(ownerPubkey)\(prefix)?shallow=true" } else { "https://_pubky.\(ownerPubkey)\(prefix)?shallow=true" } @@ -270,22 +266,19 @@ public class PubkyUnauthenticatedStorageAdapter: PubkyUnauthenticatedStorageCall public class PubkyAuthenticatedStorageAdapter: PubkyAuthenticatedStorageCallback { private let sessionId: String - private let homeserverBaseURL: String? + private let homeserverURL: HomeserverURL? private let session: URLSession - public init(sessionId: String, homeserverBaseURL: String? = nil) { + public init(sessionId: String, homeserverURL: HomeserverURL? = nil) { self.sessionId = sessionId - self.homeserverBaseURL = homeserverBaseURL - let config = URLSessionConfiguration.default - config.timeoutIntervalForRequest = 30 - config.timeoutIntervalForResource = 60 - config.httpCookieStorage = HTTPCookieStorage.shared - self.session = URLSession(configuration: config) + self.homeserverURL = homeserverURL + // Use shared network configuration for consistent timeouts and settings + self.session = PaykitNetworkConfig.shared.session } public func put(path: String, content: String) -> StorageOperationResult { - let urlString = if let baseURL = homeserverBaseURL { - "\(baseURL)\(path)" + let urlString = if let url = homeserverURL { + "\(url.value)\(path)" } else { "https://homeserver.pubky.app\(path)" } @@ -331,8 +324,8 @@ public class PubkyAuthenticatedStorageAdapter: PubkyAuthenticatedStorageCallback } public func get(path: String) -> StorageGetResult { - let urlString = if let baseURL = homeserverBaseURL { - "\(baseURL)\(path)" + let urlString = if let url = homeserverURL { + "\(url.value)\(path)" } else { "https://homeserver.pubky.app\(path)" } @@ -379,8 +372,8 @@ public class PubkyAuthenticatedStorageAdapter: PubkyAuthenticatedStorageCallback } public func delete(path: String) -> StorageOperationResult { - let urlString = if let baseURL = homeserverBaseURL { - "\(baseURL)\(path)" + let urlString = if let url = homeserverURL { + "\(url.value)\(path)" } else { "https://homeserver.pubky.app\(path)" } @@ -424,8 +417,8 @@ public class PubkyAuthenticatedStorageAdapter: PubkyAuthenticatedStorageCallback } public func list(prefix: String) -> StorageListResult { - let urlString = if let baseURL = homeserverBaseURL { - "\(baseURL)\(prefix)?shallow=true" + let urlString = if let url = homeserverURL { + "\(url.value)\(prefix)?shallow=true" } else { "https://homeserver.pubky.app\(prefix)?shallow=true" } diff --git a/Bitkit/PaykitIntegration/Services/PushNotificationService.swift b/Bitkit/PaykitIntegration/Services/PushNotificationService.swift deleted file mode 100644 index 69761812..00000000 --- a/Bitkit/PaykitIntegration/Services/PushNotificationService.swift +++ /dev/null @@ -1,247 +0,0 @@ -// -// PushNotificationService.swift -// Bitkit -// -// Service for sending push notifications to peers for Paykit operations. -// Used to wake remote devices before attempting Noise connections. -// - -import Foundation - -/// Service to send push notifications to peers -public final class PaykitPushNotificationService { - - public static let shared = PaykitPushNotificationService() - - /// Push notification platforms - public enum Platform: String, Codable { - case ios = "ios" - case android = "android" - } - - /// A registered push endpoint for a peer - public struct PushEndpoint: Codable { - public let pubkey: String - public let deviceToken: String - public let platform: Platform - public let noiseHost: String? - public let noisePort: Int? - public let noisePubkey: String? - public let createdAt: Date - - public init( - pubkey: String, - deviceToken: String, - platform: Platform, - noiseHost: String? = nil, - noisePort: Int? = nil, - noisePubkey: String? = nil - ) { - self.pubkey = pubkey - self.deviceToken = deviceToken - self.platform = platform - self.noiseHost = noiseHost - self.noisePort = noisePort - self.noisePubkey = noisePubkey - self.createdAt = Date() - } - } - - /// Errors for push notification operations - public enum PushError: LocalizedError { - case endpointNotFound - case sendFailed(String) - case invalidConfiguration - - public var errorDescription: String? { - switch self { - case .endpointNotFound: - return "Push endpoint not found for recipient" - case .sendFailed(let message): - return "Failed to send push notification: \(message)" - case .invalidConfiguration: - return "Push notification configuration is invalid" - } - } - } - - // MARK: - APNs Configuration - - /// APNs server URL (production vs sandbox) - private var apnsServer: String { - #if DEBUG - return "https://api.sandbox.push.apple.com" - #else - return "https://api.push.apple.com" - #endif - } - - /// APNs topic (bundle identifier) - private let apnsTopic = "to.bitkit" - - // MARK: - Public API - - private init() {} - - /// Send a wake notification to a peer before attempting Noise connection. - /// This wakes the recipient's device to start their Noise server. - /// - /// - Parameters: - /// - recipientPubkey: The public key of the recipient - /// - senderPubkey: The public key of the sender - /// - noiseHost: Optional host the sender will connect to - /// - noisePort: Optional port the sender will connect to - /// - directoryService: DirectoryService to discover push endpoint - public func sendWakeNotification( - to recipientPubkey: String, - from senderPubkey: String, - noiseHost: String? = nil, - noisePort: Int? = nil, - using directoryService: DirectoryService - ) async throws { - // Discover recipient's push endpoint - guard let endpoint = try await discoverPushEndpoint(recipientPubkey, using: directoryService) else { - throw PushError.endpointNotFound - } - - // Send notification based on platform - switch endpoint.platform { - case .ios: - try await sendAPNsNotification(to: endpoint, from: senderPubkey, noiseHost: noiseHost, noisePort: noisePort) - case .android: - try await sendFCMNotification(to: endpoint, from: senderPubkey, noiseHost: noiseHost, noisePort: noisePort) - } - - Logger.info("PushNotificationService: Sent wake notification to \(recipientPubkey.prefix(12))...", context: "PushNotificationService") - } - - // MARK: - Endpoint Discovery - - /// Discover push endpoint for a recipient from the Pubky directory - private func discoverPushEndpoint( - _ recipientPubkey: String, - using directoryService: DirectoryService - ) async throws -> PushEndpoint? { - // Fetch from /pub/paykit.app/v0/push/{pubkey} - // This would be stored by the recipient when they register for push notifications - - // For now, return nil as this requires the full directory integration - // In production, this would call directoryService.fetchPushEndpoint() - return nil - } - - // MARK: - APNs Notifications (iOS) - - /// Send push notification via APNs for iOS recipients - private func sendAPNsNotification( - to endpoint: PushEndpoint, - from senderPubkey: String, - noiseHost: String?, - noisePort: Int? - ) async throws { - // Build APNs payload - let payload: [String: Any] = [ - "aps": [ - "content-available": 1, // Silent push - "alert": [ - "title": "Incoming Payment Request", - "body": "Someone wants to send you a payment request" - ], - "sound": "default" - ], - "type": "paykit_noise_request", - "from_pubkey": senderPubkey, - "endpoint_host": noiseHost ?? endpoint.noiseHost ?? "", - "endpoint_port": noisePort ?? endpoint.noisePort ?? 9000, - "noise_pubkey": endpoint.noisePubkey ?? "" - ] - - guard let payloadData = try? JSONSerialization.data(withJSONObject: payload) else { - throw PushError.invalidConfiguration - } - - // Note: In a real implementation, you would need: - // 1. APNs authentication (JWT token using APNs auth key) - // 2. Proper HTTP/2 client for APNs - // 3. Error handling for APNs responses - - // For now, log that we would send the notification - Logger.info("PushNotificationService: Would send APNs notification to token \(endpoint.deviceToken.prefix(16))...", context: "PushNotificationService") - - // In production, this would be: - // try await sendHTTP2Request(to: apnsServer, deviceToken: endpoint.deviceToken, payload: payloadData) - - // Placeholder for actual implementation - this should be implemented - // using a proper APNs client library or direct HTTP/2 implementation - } - - // MARK: - FCM Notifications (Android) - - /// Send push notification via FCM for Android recipients - private func sendFCMNotification( - to endpoint: PushEndpoint, - from senderPubkey: String, - noiseHost: String?, - noisePort: Int? - ) async throws { - // Build FCM payload - let message: [String: Any] = [ - "to": endpoint.deviceToken, - "priority": "high", - "data": [ - "type": "paykit_noise_request", - "from_pubkey": senderPubkey, - "endpoint_host": noiseHost ?? endpoint.noiseHost ?? "", - "endpoint_port": noisePort ?? endpoint.noisePort ?? 9000, - "noise_pubkey": endpoint.noisePubkey ?? "" - ] - ] - - // Note: In a real implementation, you would need: - // 1. FCM server key or service account authentication - // 2. HTTP request to FCM endpoint - // 3. Error handling for FCM responses - - // For now, log that we would send the notification - Logger.info("PushNotificationService: Would send FCM notification to token \(endpoint.deviceToken.prefix(16))...", context: "PushNotificationService") - - // In production, this would typically go through a backend service - // since FCM server keys should not be embedded in client apps - } - - // MARK: - Endpoint Registration - - /// Our own device token for push notifications - private var localDeviceToken: String? - - /// Update our device token (called when APNs registration succeeds) - public func updateDeviceToken(_ token: String) { - self.localDeviceToken = token - Logger.info("PushNotificationService: Updated device token", context: "PushNotificationService") - } - - /// Publish our push endpoint to the Pubky directory. - /// This allows other users to discover how to wake our device. - /// - /// - Parameters: - /// - noiseHost: Host for our Noise server - /// - noisePort: Port for our Noise server - /// - noisePubkey: Our Noise public key - /// - directoryService: DirectoryService to publish endpoint - public func publishOurPushEndpoint( - noiseHost: String, - noisePort: Int, - noisePubkey: String, - using directoryService: DirectoryService - ) async throws { - guard let token = localDeviceToken else { - throw PushError.invalidConfiguration - } - - // In production, this would store the endpoint in the Pubky directory: - // directoryService.publishPushEndpoint(...) - - Logger.info("PushNotificationService: Published push endpoint to directory", context: "PushNotificationService") - } -} - diff --git a/Bitkit/PaykitIntegration/Services/PushRelayService.swift b/Bitkit/PaykitIntegration/Services/PushRelayService.swift new file mode 100644 index 00000000..d25bddad --- /dev/null +++ b/Bitkit/PaykitIntegration/Services/PushRelayService.swift @@ -0,0 +1,317 @@ +// +// PushRelayService.swift +// Bitkit +// +// Private push relay service client for secure wake notifications. +// Replaces public push token publishing with server-side token storage. +// + +import Foundation +import CryptoKit + +// MARK: - PushRelayService + +/// Client for the private push relay service. +/// +/// The push relay service stores push tokens server-side (never publicly) +/// and forwards authorized wake notifications to APNs/FCM. +/// +/// Benefits over public publishing: +/// - Tokens never exposed publicly (no DoS via spam) +/// - Rate limiting at relay level +/// - Sender authentication required +public final class PushRelayService { + + public static let shared = PushRelayService() + + // MARK: - Configuration + + /// Relay service base URL + public var baseURL: String { + if let envURL = ProcessInfo.processInfo.environment["PUSH_RELAY_URL"] { + return envURL + } + #if DEBUG + return "https://push-staging.paykit.app/v1" + #else + return "https://push.paykit.app/v1" + #endif + } + + /// Whether to fall back to homeserver discovery during migration + public var fallbackToHomeserver: Bool { + ProcessInfo.processInfo.environment["PUSH_RELAY_FALLBACK"] == "true" + } + + /// Whether push relay is enabled + public var isEnabled: Bool { + ProcessInfo.processInfo.environment["PUSH_RELAY_ENABLED"] != "false" + } + + // MARK: - State + + private var currentRelayId: String? + private var registrationExpiresAt: Date? + + private let keyManager = PaykitKeyManager.shared + private let urlSession: URLSession + + // MARK: - Types + + public enum WakeType: String, Codable { + case noiseConnect = "noise_connect" + case paymentReceived = "payment_received" + case channelUpdate = "channel_update" + } + + public struct RegistrationResponse: Codable { + let status: String + let relayId: String + let expiresAt: Int64 + + enum CodingKeys: String, CodingKey { + case status + case relayId = "relay_id" + case expiresAt = "expires_at" + } + } + + public struct WakeResponse: Codable { + let status: String + let wakeId: String? + + enum CodingKeys: String, CodingKey { + case status + case wakeId = "wake_id" + } + } + + public enum PushRelayError: LocalizedError { + case notConfigured + case invalidSignature + case rateLimited(retryAfter: Int) + case recipientNotFound + case networkError(Error) + case serverError(String) + case disabled + + public var errorDescription: String? { + switch self { + case .notConfigured: + return "Push relay not configured - missing pubkey" + case .invalidSignature: + return "Invalid signature for relay request" + case .rateLimited(let retryAfter): + return "Rate limited, retry after \(retryAfter) seconds" + case .recipientNotFound: + return "Recipient not registered for push notifications" + case .networkError(let error): + return "Network error: \(error.localizedDescription)" + case .serverError(let message): + return "Server error: \(message)" + case .disabled: + return "Push relay is disabled" + } + } + } + + // MARK: - Initialization + + private init() { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 30 + config.timeoutIntervalForResource = 60 + self.urlSession = URLSession(configuration: config) + } + + // MARK: - Registration + + /// Register device for push notifications via relay. + /// + /// - Parameters: + /// - token: APNs device token (hex encoded) + /// - capabilities: Notification types to receive + /// - Returns: Registration response with relay ID and expiry + public func register( + token: String, + capabilities: [String] = ["wake", "payment_received"] + ) async throws -> RegistrationResponse { + guard isEnabled else { + throw PushRelayError.disabled + } + + guard let pubkey = keyManager.getCurrentPublicKeyZ32() else { + throw PushRelayError.notConfigured + } + + let body: [String: Any] = [ + "platform": "ios", + "token": token, + "capabilities": capabilities, + "device_id": keyManager.getDeviceId() + ] + + let response: RegistrationResponse = try await makeAuthenticatedRequest( + method: "POST", + path: "/register", + body: body, + pubkey: pubkey + ) + + currentRelayId = response.relayId + registrationExpiresAt = Date(timeIntervalSince1970: Double(response.expiresAt)) + + Logger.info("Registered with push relay, expires: \(registrationExpiresAt!)", context: "PushRelayService") + + return response + } + + /// Unregister from push relay. + public func unregister() async throws { + guard isEnabled else { return } + + guard let pubkey = keyManager.getCurrentPublicKeyZ32() else { + throw PushRelayError.notConfigured + } + + let body: [String: Any] = [ + "device_id": keyManager.getDeviceId() + ] + + let _: EmptyResponse = try await makeAuthenticatedRequest( + method: "DELETE", + path: "/register", + body: body, + pubkey: pubkey + ) + + currentRelayId = nil + registrationExpiresAt = nil + + Logger.info("Unregistered from push relay", context: "PushRelayService") + } + + /// Check if registration needs renewal (within 7 days of expiry). + public var needsRenewal: Bool { + guard let expiresAt = registrationExpiresAt else { return true } + let renewalThreshold = expiresAt.addingTimeInterval(-7 * 24 * 60 * 60) + return Date() > renewalThreshold + } + + // MARK: - Wake Notifications + + /// Send a wake notification to a recipient. + /// + /// - Parameters: + /// - recipientPubkey: The recipient's z32 pubkey + /// - wakeType: Type of wake notification + /// - payload: Optional encrypted payload + /// - Returns: Wake response with status + public func wake( + recipientPubkey: String, + wakeType: WakeType, + payload: Data? = nil + ) async throws -> WakeResponse { + guard isEnabled else { + throw PushRelayError.disabled + } + + guard let senderPubkey = keyManager.getCurrentPublicKeyZ32() else { + throw PushRelayError.notConfigured + } + + var body: [String: Any] = [ + "recipient_pubkey": recipientPubkey, + "wake_type": wakeType.rawValue, + "sender_pubkey": senderPubkey, + "nonce": generateNonce() + ] + + if let payload = payload { + body["payload"] = payload.base64EncodedString() + } + + let response: WakeResponse = try await makeAuthenticatedRequest( + method: "POST", + path: "/wake", + body: body, + pubkey: senderPubkey + ) + + Logger.debug("Wake sent to \(recipientPubkey.prefix(12))..., status: \(response.status)", context: "PushRelayService") + + return response + } + + // MARK: - Private Helpers + + private struct EmptyResponse: Codable {} + + private func makeAuthenticatedRequest( + method: String, + path: String, + body: [String: Any], + pubkey: String + ) async throws -> T { + let url = URL(string: baseURL + path)! + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + // Serialize body + let bodyData = try JSONSerialization.data(withJSONObject: body) + request.httpBody = bodyData + + // Add authentication headers + let timestamp = Int64(Date().timeIntervalSince1970) + let bodyHash = SHA256.hash(data: bodyData).compactMap { String(format: "%02x", $0) }.joined() + let message = "\(method):\(path):\(timestamp):\(bodyHash)" + + // Sign with Ed25519 via Pubky Ring + let signature = try await signMessage(message, pubkey: pubkey) + + request.setValue(signature, forHTTPHeaderField: "X-Pubky-Signature") + request.setValue(String(timestamp), forHTTPHeaderField: "X-Pubky-Timestamp") + request.setValue(pubkey, forHTTPHeaderField: "X-Pubky-Pubkey") + + // Make request + let (data, response) = try await urlSession.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw PushRelayError.networkError(URLError(.badServerResponse)) + } + + switch httpResponse.statusCode { + case 200, 201: + let decoder = JSONDecoder() + return try decoder.decode(T.self, from: data) + + case 401: + throw PushRelayError.invalidSignature + + case 404: + throw PushRelayError.recipientNotFound + + case 429: + let retryAfter = Int(httpResponse.value(forHTTPHeaderField: "Retry-After") ?? "60") ?? 60 + throw PushRelayError.rateLimited(retryAfter: retryAfter) + + default: + let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error" + throw PushRelayError.serverError(errorMessage) + } + } + + private func signMessage(_ message: String, pubkey: String) async throws -> String { + // Request Ed25519 signature from Pubky Ring + // Ring holds the secret key and performs the signing + return try await PubkyRingBridge.shared.requestSignature(message: message) + } + + private func generateNonce() -> String { + var bytes = [UInt8](repeating: 0, count: 16) + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + return bytes.map { String(format: "%02x", $0) }.joined() + } +} + diff --git a/Bitkit/PaykitIntegration/Services/SpendingLimitManager.swift b/Bitkit/PaykitIntegration/Services/SpendingLimitManager.swift index 4663e47e..8e2361a3 100644 --- a/Bitkit/PaykitIntegration/Services/SpendingLimitManager.swift +++ b/Bitkit/PaykitIntegration/Services/SpendingLimitManager.swift @@ -46,49 +46,57 @@ public class SpendingLimitManager { limitSats: Int64, period: String = "daily" ) throws -> PeerSpendingLimitFfi { - guard let manager = ffiManager else { - throw SpendingLimitError.notInitialized + try queue.sync { + guard let manager = ffiManager else { + throw SpendingLimitError.notInitialized + } + + let ffiLimit = try manager.setPeerSpendingLimit( + peerPubkey: peerPubkey, + limitSats: limitSats, + period: period + ) + Logger.info("Set spending limit for \(peerPubkey): \(limitSats) sats (\(period))", context: "SpendingLimitManager") + + return ffiLimit } - - let ffiLimit = try manager.setPeerSpendingLimit( - peerPubkey: peerPubkey, - limitSats: limitSats, - period: period - ) - Logger.info("Set spending limit for \(peerPubkey): \(limitSats) sats (\(period))", context: "SpendingLimitManager") - - return ffiLimit } /// Get the spending limit for a peer /// - Parameter peerPubkey: The peer's public key /// - Returns: The spending limit if set public func getSpendingLimit(peerPubkey: String) throws -> PeerSpendingLimitFfi? { - guard let manager = ffiManager else { - throw SpendingLimitError.notInitialized + try queue.sync { + guard let manager = ffiManager else { + throw SpendingLimitError.notInitialized + } + + return try manager.getPeerSpendingLimit(peerPubkey: peerPubkey) } - - return try manager.getPeerSpendingLimit(peerPubkey: peerPubkey) } /// List all spending limits /// - Returns: List of all configured spending limits public func listSpendingLimits() throws -> [PeerSpendingLimitFfi] { - guard let manager = ffiManager else { - throw SpendingLimitError.notInitialized + try queue.sync { + guard let manager = ffiManager else { + throw SpendingLimitError.notInitialized + } + + return try manager.listSpendingLimits() } - - return try manager.listSpendingLimits() } /// Remove the spending limit for a peer public func removeSpendingLimit(peerPubkey: String) throws { - guard let manager = ffiManager else { - throw SpendingLimitError.notInitialized + try queue.sync { + guard let manager = ffiManager else { + throw SpendingLimitError.notInitialized + } + + try manager.removePeerSpendingLimit(peerPubkey: peerPubkey) + Logger.info("Removed spending limit for \(peerPubkey)", context: "SpendingLimitManager") } - - try manager.removePeerSpendingLimit(peerPubkey: peerPubkey) - Logger.info("Removed spending limit for \(peerPubkey)", context: "SpendingLimitManager") } // MARK: - Atomic Spending Operations @@ -100,25 +108,29 @@ public class SpendingLimitManager { /// - Returns: A reservation if successful /// - Throws: If the amount would exceed the limit public func tryReserveSpending(peerPubkey: String, amountSats: Int64) throws -> SpendingReservationFfi { - guard let manager = ffiManager else { - throw SpendingLimitError.notInitialized + try queue.sync { + guard let manager = ffiManager else { + throw SpendingLimitError.notInitialized + } + + let reservation = try manager.tryReserveSpending(peerPubkey: peerPubkey, amountSats: amountSats) + Logger.debug("Reserved \(amountSats) sats for \(peerPubkey), id: \(reservation.reservationId)", context: "SpendingLimitManager") + + return reservation } - - let reservation = try manager.tryReserveSpending(peerPubkey: peerPubkey, amountSats: amountSats) - Logger.debug("Reserved \(amountSats) sats for \(peerPubkey), id: \(reservation.reservationId)", context: "SpendingLimitManager") - - return reservation } /// Commit a spending reservation (marks the spending as final) /// This operation is idempotent. public func commitSpending(reservationId: String) throws { - guard let manager = ffiManager else { - throw SpendingLimitError.notInitialized + try queue.sync { + guard let manager = ffiManager else { + throw SpendingLimitError.notInitialized + } + + try manager.commitSpending(reservationId: reservationId) + Logger.info("Committed spending for reservation: \(reservationId)", context: "SpendingLimitManager") } - - try manager.commitSpending(reservationId: reservationId) - Logger.info("Committed spending for reservation: \(reservationId)", context: "SpendingLimitManager") } /// Commit a spending reservation (marks the spending as final) @@ -129,12 +141,14 @@ public class SpendingLimitManager { /// Rollback a spending reservation (releases the reserved amount) /// This operation is idempotent. public func rollbackSpending(reservationId: String) throws { - guard let manager = ffiManager else { - throw SpendingLimitError.notInitialized + try queue.sync { + guard let manager = ffiManager else { + throw SpendingLimitError.notInitialized + } + + try manager.rollbackSpending(reservationId: reservationId) + Logger.debug("Rolled back spending for reservation: \(reservationId)", context: "SpendingLimitManager") } - - try manager.rollbackSpending(reservationId: reservationId) - Logger.debug("Rolled back spending for reservation: \(reservationId)", context: "SpendingLimitManager") } /// Rollback a spending reservation (releases the reserved amount) @@ -145,19 +159,23 @@ public class SpendingLimitManager { /// Check if spending an amount would exceed the limit (non-blocking check) /// - Returns: Result containing whether the limit would be exceeded and remaining details public func wouldExceedLimit(peerPubkey: String, amountSats: Int64) throws -> SpendingCheckResultFfi { - guard let manager = ffiManager else { - throw SpendingLimitError.notInitialized + try queue.sync { + guard let manager = ffiManager else { + throw SpendingLimitError.notInitialized + } + + return try manager.wouldExceedSpendingLimit(peerPubkey: peerPubkey, amountSats: amountSats) } - - return try manager.wouldExceedSpendingLimit(peerPubkey: peerPubkey, amountSats: amountSats) } /// Get the number of active (in-flight) reservations public func activeReservationsCount() throws -> UInt32 { - guard let manager = ffiManager else { - throw SpendingLimitError.notInitialized + try queue.sync { + guard let manager = ffiManager else { + throw SpendingLimitError.notInitialized + } + return try manager.activeReservationsCount() } - return try manager.activeReservationsCount() } // MARK: - Convenience diff --git a/Bitkit/PaykitIntegration/Services/SubscriptionBackgroundService.swift b/Bitkit/PaykitIntegration/Services/SubscriptionBackgroundService.swift index 952909ba..ce151823 100644 --- a/Bitkit/PaykitIntegration/Services/SubscriptionBackgroundService.swift +++ b/Bitkit/PaykitIntegration/Services/SubscriptionBackgroundService.swift @@ -24,12 +24,19 @@ public class SubscriptionBackgroundService { /// Hours before due to send notification (default 24 hours) private let notifyBeforeHours: Int = 24 + /// Retry configuration for failed payments + private let maxRetries: Int = 3 + private let initialRetryDelay: TimeInterval = 5.0 // 5 seconds + private let maxRetryDelay: TimeInterval = 60.0 // 1 minute max + private let subscriptionStorage: SubscriptionStorage private let autoPayStorage: AutoPayStorage + private let autoPayEvaluator: AutoPayEvaluatorService private init(identityName: String = "default") { self.subscriptionStorage = SubscriptionStorage(identityName: identityName) self.autoPayStorage = AutoPayStorage(identityName: identityName) + self.autoPayEvaluator = AutoPayEvaluatorService(identityName: identityName) } // MARK: - Registration @@ -155,47 +162,86 @@ public class SubscriptionBackgroundService { return } - // Evaluate auto-pay using storage directly (non-MainActor) - let autoPayStorage = AutoPayStorage.shared - let settings = autoPayStorage.getSettings() - - // Check if auto-pay is enabled - guard settings.isEnabled else { - Logger.info("SubscriptionBackgroundService: Auto-pay disabled, needs manual approval", context: "SubscriptionBackgroundService") - await sendPaymentNeedsApprovalNotification(subscription: subscription, reason: "Auto-pay is disabled") - return - } + // Evaluate auto-pay using dedicated evaluator service (non-MainActor safe) + let result = autoPayEvaluator.evaluateForBackground( + peerPubkey: subscription.providerPubkey, + peerName: subscription.providerName, + amount: Int64(subscription.amountSats), + methodId: "lightning", + isSubscription: true + ) - // Check spending limit - do { - let checkResult = try SpendingLimitManager.shared.wouldExceedLimit( + switch result { + case .approved(let ruleId, let ruleName): + Logger.info("SubscriptionBackgroundService: Auto-pay approved by rule: \(ruleName ?? ruleId)", context: "SubscriptionBackgroundService") + try await executePaymentWithRetry(subscription) + + // Record successful auto-pay + autoPayEvaluator.recordPayment( peerPubkey: subscription.providerPubkey, - amountSats: Int64(subscription.amountSats) + peerName: subscription.providerName, + amount: Int64(subscription.amountSats), + approved: true, + reason: "Auto-approved by rule: \(ruleName ?? ruleId)" ) - if checkResult.wouldExceed { - Logger.info("SubscriptionBackgroundService: Auto-pay denied: Would exceed spending limit", context: "SubscriptionBackgroundService") - await sendPaymentNeedsApprovalNotification(subscription: subscription, reason: "Would exceed spending limit") - return - } - } catch { - // No spending limit configured - continue with approval - Logger.debug("SubscriptionBackgroundService: No spending limit configured for peer", context: "SubscriptionBackgroundService") + case .denied(let reason): + Logger.info("SubscriptionBackgroundService: Auto-pay denied: \(reason)", context: "SubscriptionBackgroundService") + await sendPaymentNeedsApprovalNotification(subscription: subscription, reason: reason) + + case .needsApproval: + Logger.info("SubscriptionBackgroundService: Payment needs manual approval", context: "SubscriptionBackgroundService") + await sendPaymentNeedsApprovalNotification(subscription: subscription, reason: "Manual approval required") + + case .needsBiometric: + // This case shouldn't occur when using evaluateForBackground(), but handle it safely + Logger.info("SubscriptionBackgroundService: Biometric required, deferring to foreground", context: "SubscriptionBackgroundService") + await sendPaymentNeedsApprovalNotification(subscription: subscription, reason: "Biometric authentication required") } + } + + /// Execute payment with exponential backoff retry logic. + /// + /// Retry strategy: + /// - Initial delay: 5 seconds + /// - Exponential backoff: delay doubles each retry (5s, 10s, 20s) + /// - Maximum delay capped at 60 seconds + /// - Maximum retries: 3 + private func executePaymentWithRetry(_ subscription: BitkitSubscription) async throws { + var lastError: Error? + var currentDelay = initialRetryDelay - // Check for matching rule - if let rule = autoPayStorage.getRule(for: subscription.providerPubkey) { - if let maxAmount = rule.maxAmountSats, subscription.amountSats > maxAmount { - Logger.info("SubscriptionBackgroundService: Auto-pay denied: Amount exceeds rule limit", context: "SubscriptionBackgroundService") - await sendPaymentNeedsApprovalNotification(subscription: subscription, reason: "Amount exceeds rule limit") - return + for attempt in 1...maxRetries { + do { + try await executePayment(subscription) + return // Success + } catch { + lastError = error + + if attempt < maxRetries { + Logger.warn( + "SubscriptionBackgroundService: Payment attempt \(attempt)/\(maxRetries) failed: \(error.localizedDescription), retrying in \(currentDelay)s", + context: "SubscriptionBackgroundService" + ) + + // Wait with exponential backoff + try? await Task.sleep(nanoseconds: UInt64(currentDelay * 1_000_000_000)) + + // Double the delay for next retry, capped at max + currentDelay = min(currentDelay * 2, maxRetryDelay) + } else { + Logger.error( + "SubscriptionBackgroundService: All \(maxRetries) payment attempts failed for subscription \(subscription.id)", + context: "SubscriptionBackgroundService" + ) + } } - - Logger.info("SubscriptionBackgroundService: Auto-pay approved by rule: \(rule.name)", context: "SubscriptionBackgroundService") - try await executePayment(subscription) - } else { - Logger.info("SubscriptionBackgroundService: No matching rule, needs manual approval", context: "SubscriptionBackgroundService") - await sendPaymentNeedsApprovalNotification(subscription: subscription, reason: "No auto-pay rule for this peer") + } + + // All retries exhausted + if let error = lastError { + await sendPaymentFailedNotification(subscription: subscription, reason: error.localizedDescription) + throw error } } diff --git a/Bitkit/PaykitIntegration/Types/HomeserverTypes.swift b/Bitkit/PaykitIntegration/Types/HomeserverTypes.swift new file mode 100644 index 00000000..515cfbf0 --- /dev/null +++ b/Bitkit/PaykitIntegration/Types/HomeserverTypes.swift @@ -0,0 +1,311 @@ +// +// HomeserverTypes.swift +// Bitkit +// +// Type-safe wrappers for homeserver-related identifiers. +// Prevents accidental confusion between pubkeys, URLs, and session secrets. +// + +import Foundation + +// MARK: - HomeserverPubkey + +/// A z32-encoded Ed25519 public key identifying a homeserver. +/// +/// This is the pubkey of the homeserver operator, NOT a URL. +/// Used for: +/// - Identifying which homeserver a user is registered with +/// - Constructing storage paths +/// - Authenticating homeserver responses +/// +/// Example: `pk:8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo` +public struct HomeserverPubkey: Hashable, Codable, CustomStringConvertible { + + /// The raw z32-encoded pubkey string + public let value: String + + /// Create from a z32-encoded pubkey string + /// - Parameter value: The z32 pubkey (with or without `pk:` prefix) + public init(_ value: String) { + // Normalize: remove pk: prefix if present + if value.hasPrefix("pk:") { + self.value = String(value.dropFirst(3)) + } else { + self.value = value + } + } + + /// Validate the pubkey format + public var isValid: Bool { + // z32 pubkeys are 52 characters (256 bits / 5 bits per char) + value.count == 52 && value.allSatisfy { c in + "ybndrfg8ejkmcpqxot1uwisza345h769".contains(c) + } + } + + /// Returns the pubkey with pk: prefix + public var withPrefix: String { + "pk:\(value)" + } + + public var description: String { + "HomeserverPubkey(\(value.prefix(12))...)" + } +} + +// MARK: - HomeserverURL + +/// A resolved HTTPS URL for a homeserver's API endpoint. +/// +/// This is the actual URL to make HTTP requests to, NOT a pubkey. +/// Resolved from a HomeserverPubkey via DNS or configuration. +/// +/// Example: `https://homeserver.pubky.app` +public struct HomeserverURL: Hashable, Codable, CustomStringConvertible { + + /// The resolved HTTPS URL string + public let value: String + + /// Create from a URL string + /// - Parameter value: The HTTPS URL for the homeserver + public init(_ value: String) { + // Normalize: ensure https and no trailing slash + var normalized = value + if !normalized.hasPrefix("https://") && !normalized.hasPrefix("http://") { + normalized = "https://\(normalized)" + } + if normalized.hasSuffix("/") { + normalized = String(normalized.dropLast()) + } + self.value = normalized + } + + /// Validate the URL format + public var isValid: Bool { + URL(string: value) != nil && value.hasPrefix("https://") + } + + /// Get the URL object + public var url: URL? { + URL(string: value) + } + + /// Construct a full URL for a pubky path + /// - Parameters: + /// - ownerPubkey: The owner's pubkey + /// - path: The path within the owner's storage + /// - Returns: Full URL for the resource + public func urlForPath(owner ownerPubkey: String, path: String) -> URL? { + URL(string: "\(value)/\(ownerPubkey)\(path)") + } + + public var description: String { + "HomeserverURL(\(value))" + } +} + +// MARK: - SessionSecret + +/// A session secret token for authenticated homeserver operations. +/// +/// This is a sensitive credential - handle with care. +/// Never log or expose in URLs. +public struct SessionSecret: Hashable, CustomStringConvertible { + + /// The raw session secret bytes (hex encoded for transport) + public let hexValue: String + + /// Create from hex-encoded secret + public init(hex: String) { + self.hexValue = hex + } + + /// Validate the secret format + public var isValid: Bool { + // Session secrets are typically 32 bytes = 64 hex chars + hexValue.count >= 32 && hexValue.allSatisfy { $0.isHexDigit } + } + + /// Redacted description for logging + public var description: String { + "SessionSecret(***)" + } + + /// Get the raw bytes + public var bytes: Data? { + guard hexValue.count % 2 == 0 else { return nil } + var data = Data(capacity: hexValue.count / 2) + var index = hexValue.startIndex + while index < hexValue.endIndex { + let nextIndex = hexValue.index(index, offsetBy: 2) + if let byte = UInt8(hexValue[index.. HomeserverURL { + // 1. Check for override (testing/development) + if let override = overrideURL { + return override + } + + // 2. Check cache + if let cached = cache[pubkey], cached.expires > Date() { + return cached.url + } + + // 3. Check known mappings + if let urlString = knownHomeservers[pubkey.value] { + let url = HomeserverURL(urlString) + // Cache for 1 hour + cache[pubkey] = (url, Date().addingTimeInterval(3600)) + return url + } + + // 4. Fall back to default + // TODO: Implement DNS-based resolution via _pubky. + let defaultURL = PubkyConfig.defaultHomeserverURL + cache[pubkey] = (defaultURL, Date().addingTimeInterval(3600)) + return defaultURL + } + + /// Construct a full URL for accessing a user's data on a homeserver. + /// + /// - Parameters: + /// - owner: The owner's pubkey + /// - path: The path within their storage + /// - homeserver: Optional specific homeserver (defaults to owner's homeserver) + /// - Returns: Full URL for the resource + public func urlFor(owner: OwnerPubkey, path: String, homeserver: HomeserverPubkey? = nil) -> URL? { + let resolvedURL = resolve(pubkey: homeserver ?? PubkyConfig.defaultHomeserverPubkey) + return resolvedURL.urlForPath(owner: owner.value, path: path) + } + + /// The base URL for authenticated operations. + /// + /// - Parameter session: The authenticated session + /// - Returns: Base URL for the session's homeserver + public func baseURLForSession(_ session: PubkySession) -> HomeserverURL { + // For now, all sessions use the default homeserver + // In production, this would be stored with the session + return PubkyConfig.defaultHomeserverURL + } + + /// Clear the resolution cache + public func clearCache() { + cache.removeAll() + } +} + diff --git a/Bitkit/PaykitIntegration/Utils/PaykitDeepLinkValidator.swift b/Bitkit/PaykitIntegration/Utils/PaykitDeepLinkValidator.swift new file mode 100644 index 00000000..c56d1196 --- /dev/null +++ b/Bitkit/PaykitIntegration/Utils/PaykitDeepLinkValidator.swift @@ -0,0 +1,136 @@ +// +// PaykitDeepLinkValidator.swift +// Bitkit +// +// Validates Paykit deep links for security and correctness. +// + +import Foundation + +/// Validates Paykit deep links before processing. +/// +/// Ensures deep links have: +/// - Valid scheme (paykit:// or bitkit://) +/// - Valid host (payment-request for bitkit://) +/// - Required parameters present and non-empty +/// - Parameters within expected format/length constraints +public enum PaykitDeepLinkValidator { + + // MARK: - Validation Result + + public enum ValidationResult { + case valid(requestId: String, fromPubkey: String) + case invalid(reason: String) + } + + // MARK: - Constants + + /// Maximum length for requestId parameter (UUID format is 36 chars) + private static let maxRequestIdLength = 64 + + /// Maximum length for pubkey parameter (z-base32 encoded pubkeys) + private static let maxPubkeyLength = 256 + + /// Allowed schemes for Paykit deep links + private static let allowedSchemes: Set = ["paykit", "bitkit"] + + /// Valid host for bitkit:// scheme + private static let bitkitPaymentHost = "payment-request" + + // MARK: - Validation + + /// Validate a Paykit deep link URL. + /// + /// Valid formats: + /// - `paykit://payment-request?requestId=xxx&from=yyy` + /// - `bitkit://payment-request?requestId=xxx&from=yyy` + /// + /// - Parameter url: The URL to validate. + /// - Returns: Validation result with extracted parameters or reason for failure. + public static func validate(_ url: URL) -> ValidationResult { + // Check scheme + guard let scheme = url.scheme?.lowercased(), + allowedSchemes.contains(scheme) else { + return .invalid(reason: "Invalid URL scheme") + } + + // For bitkit:// scheme, host must be "payment-request" + if scheme == "bitkit" { + guard url.host?.lowercased() == bitkitPaymentHost else { + return .invalid(reason: "Invalid payment request host") + } + } + + // Parse query parameters + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let queryItems = components.queryItems else { + return .invalid(reason: "Cannot parse URL components") + } + + // Extract required parameters + let requestId = queryItems.first(where: { $0.name == "requestId" })?.value?.trimmingCharacters(in: .whitespaces) + let fromPubkey = queryItems.first(where: { $0.name == "from" })?.value?.trimmingCharacters(in: .whitespaces) + + // Validate requestId + guard let requestId, !requestId.isEmpty else { + return .invalid(reason: "Missing or empty requestId parameter") + } + + if requestId.count > maxRequestIdLength { + return .invalid(reason: "requestId exceeds maximum length") + } + + // Basic format check - requestId should be alphanumeric with dashes/underscores + let requestIdPattern = "^[a-zA-Z0-9_-]+$" + guard requestId.range(of: requestIdPattern, options: .regularExpression) != nil else { + return .invalid(reason: "requestId contains invalid characters") + } + + // Validate fromPubkey + guard let fromPubkey, !fromPubkey.isEmpty else { + return .invalid(reason: "Missing or empty from parameter") + } + + if fromPubkey.count > maxPubkeyLength { + return .invalid(reason: "from parameter exceeds maximum length") + } + + // Basic format check - pubkey should be alphanumeric (z-base32) + let pubkeyPattern = "^[a-zA-Z0-9]+$" + guard fromPubkey.range(of: pubkeyPattern, options: .regularExpression) != nil else { + return .invalid(reason: "from parameter contains invalid characters") + } + + return .valid(requestId: requestId, fromPubkey: fromPubkey) + } + + /// Check if a URL is a valid Paykit payment request deep link. + /// + /// - Parameter url: The URL to check. + /// - Returns: True if valid, false otherwise. + public static func isValidPaykitDeepLink(_ url: URL) -> Bool { + if case .valid = validate(url) { + return true + } + return false + } + + /// Check if a URL has a Paykit scheme (paykit:// or bitkit://payment-request) + /// + /// - Parameter url: The URL to check. + /// - Returns: True if this is a Paykit URL, false otherwise. + public static func isPaykitURL(_ url: URL) -> Bool { + guard let scheme = url.scheme?.lowercased() else { return false } + + if scheme == "paykit" { + return true + } + + if scheme == "bitkit" && url.host?.lowercased() == bitkitPaymentHost { + return true + } + + return false + } +} + diff --git a/Bitkit/PaykitIntegration/ViewModels/AutoPayViewModel.swift b/Bitkit/PaykitIntegration/ViewModels/AutoPayViewModel.swift index c32aa0b3..4486d82b 100644 --- a/Bitkit/PaykitIntegration/ViewModels/AutoPayViewModel.swift +++ b/Bitkit/PaykitIntegration/ViewModels/AutoPayViewModel.swift @@ -20,11 +20,13 @@ class AutoPayViewModel: ObservableObject { @Published var spentToday: Int64 = 0 private let autoPayStorage: AutoPayStorage + private let autoPayEvaluator: AutoPayEvaluatorService private let identityName: String init(identityName: String = "default") { self.identityName = identityName self.autoPayStorage = AutoPayStorage(identityName: identityName) + self.autoPayEvaluator = AutoPayEvaluatorService(identityName: identityName) self.settings = autoPayStorage.getSettings() } @@ -75,86 +77,58 @@ class AutoPayViewModel: ObservableObject { } func recordPayment(peerPubkey: String, peerName: String, amount: Int64, approved: Bool, reason: String = "") { - let entry = AutoPayHistoryEntry( + // Delegate to the evaluator service for storage + autoPayEvaluator.recordPayment( peerPubkey: peerPubkey, peerName: peerName, amount: amount, - wasApproved: approved, + approved: approved, reason: reason ) - try? autoPayStorage.saveHistoryEntry(entry) + // Reload local state for UI updates loadHistory() calculateSpentToday() } - /// Evaluate if a payment should be auto-approved - /// Implements AutopayEvaluator protocol for PaymentRequestService + /// Evaluate if a payment should be auto-approved. + /// + /// Delegates to `AutoPayEvaluatorService` for the actual evaluation logic, + /// but handles UI-specific side effects like notifications. + /// + /// - Parameters: + /// - peerPubkey: The peer's public key. + /// - peerName: The peer's display name. + /// - amount: Payment amount in satoshis. + /// - methodId: The payment method identifier. + /// - isSubscription: Whether this is a subscription payment. + /// - Returns: The evaluation result. func evaluate(peerPubkey: String, peerName: String, amount: Int64, methodId: String, isSubscription: Bool = false) -> AutopayEvaluationResult { - // Check if autopay is enabled - guard settings.isEnabled else { - return .denied(reason: "Auto-pay is disabled") - } - - // Check per-payment limit - if amount > settings.maxPerPayment { - if settings.confirmHighValue { - return .needsApproval - } - return .denied(reason: "Exceeds max per payment") - } + // Delegate to the evaluator service for the actual logic + let result = autoPayEvaluator.evaluate( + peerPubkey: peerPubkey, + peerName: peerName, + amount: amount, + methodId: methodId, + isSubscription: isSubscription + ) - // Check global daily limit - if spentToday + amount > settings.globalDailyLimit { - if settings.notifyOnLimitReached { + // Handle UI-specific side effects based on result + switch result { + case .denied(let reason): + if reason.contains("daily limit") && settings.notifyOnLimitReached { sendLimitReachedNotification() } - return .denied(reason: "Would exceed daily limit") - } - - // Check if first payment to peer requires confirmation - let isNewPeer = !peerLimits.contains { $0.peerPubkey == peerPubkey } - if isNewPeer && settings.confirmFirstPayment { - if settings.notifyOnNewPeer { + case .needsApproval: + let isNewPeer = !peerLimits.contains { $0.peerPubkey == peerPubkey } + if isNewPeer && settings.notifyOnNewPeer { sendNewPeerNotification(peerName: peerName) } - return .needsApproval - } - - // Check subscription confirmation requirement - if isSubscription && settings.confirmSubscriptions { - return .needsApproval - } - - // Check biometric for large amounts - if settings.biometricForLarge && amount > 100000 { - return .needsBiometric - } - - // Check peer-specific limit - if let peerLimitIndex = peerLimits.firstIndex(where: { $0.peerPubkey == peerPubkey }) { - var peerLimit = peerLimits[peerLimitIndex] - peerLimit.resetIfNeeded() - - // Update if reset occurred - if peerLimit.spentSats != peerLimits[peerLimitIndex].spentSats { - peerLimits[peerLimitIndex] = peerLimit - try? autoPayStorage.savePeerLimit(peerLimit) - } - - if peerLimit.spentSats + amount > peerLimit.limitSats { - return .denied(reason: "Would exceed peer limit") - } - } - - // Check auto-pay rules - for rule in rules where rule.isEnabled { - if rule.matches(amount: amount, method: methodId, peer: peerPubkey) { - return .approved(ruleId: rule.id, ruleName: rule.name) - } + default: + break } - return .needsApproval + return result } // MARK: - Notifications diff --git a/Docs/BITKIT_PAYKIT_INTEGRATION_MASTERGUIDE.md b/Docs/BITKIT_PAYKIT_INTEGRATION_MASTERGUIDE.md new file mode 100644 index 00000000..dd1eccc9 --- /dev/null +++ b/Docs/BITKIT_PAYKIT_INTEGRATION_MASTERGUIDE.md @@ -0,0 +1,2420 @@ +# Bitkit + Paykit Integration Master Guide + +> **For Synonym Development Team** +> **Version**: 1.1 +> **Last Updated**: December 23, 2025 +> **Status**: Reference Implementation - Production Verification Required + +This guide documents the complete integration of Paykit into Bitkit iOS, Bitkit Android, and Pubky Ring. It serves as a detailed map for production developers to follow, including all steps, quirks, stubs, and future work. + +**Implementation Status**: +- Core architecture and features implemented +- Security hardening applied (Phases 1-4) +- Documentation accurate to current code state +- End-to-end verification required before production deployment + +--- + +## Table of Contents + +1. [Executive Summary](#1-executive-summary) +2. [Architecture Overview](#2-architecture-overview) +3. [Prerequisites](#3-prerequisites) +4. [Building paykit-rs](#4-building-paykit-rs) + - [4.5 Building pubky-noise](#45-building-pubky-noise-required-for-noise-payments) +5. [iOS Integration](#5-ios-integration) +6. [Android Integration](#6-android-integration) +7. [Pubky Ring Integration](#7-pubky-ring-integration) + - [7.1 Native Module Architecture](#71-native-module-architecture-pubky-noise-in-ring) + - [7.2 Paykit Connect Action](#72-paykit-connect-action-ring-side-implementation) + - [7.3 Bitkit-side Session and Key Handling](#73-bitkit-side-session-and-key-handling) +8. [Feature Implementation Guide](#8-feature-implementation-guide) +9. [Known Quirks & Footguns](#9-known-quirks--footguns) +10. [Stubs & Mocks Inventory](#10-stubs--mocks-inventory) +11. [Testing Requirements](#11-testing-requirements) +12. [Production Configuration](#12-production-configuration) +13. [Security Checklist](#13-security-checklist) +14. [Troubleshooting](#14-troubleshooting) +15. [Future Work](#15-future-work) +16. [Production Implementation Checklist](#16-production-implementation-checklist) +17. [Architectural Hardening](#17-architectural-hardening) ⭐ NEW + +**Related Documents**: +- 📘 [PHASE_1-4_IMPROVEMENTS.md](PHASE_1-4_IMPROVEMENTS.md) - Detailed implementation summary +- 🔒 [SECURITY_ARCHITECTURE.md](SECURITY_ARCHITECTURE.md) - Security model and threat analysis +- 🔔 [PUSH_RELAY_DESIGN.md](PUSH_RELAY_DESIGN.md) - Push relay service specification + +--- + +## 1. Executive Summary + +### What is Paykit? + +Paykit is a decentralized payment protocol built on Pubky that enables: +- **Payment Method Discovery**: Query public directories to find how someone accepts payments +- **Encrypted Payment Channels**: Noise Protocol (Noise_IK) for secure payment negotiation +- **Multi-Method Support**: Bitcoin onchain, Lightning, and extensible to other methods +- **Subscriptions & Auto-Pay**: Recurring payments with cryptographic agreements + +### What This Integration Accomplishes + +| Feature | iOS | Android | Ring | +|---------|-----|---------|------| +| Payment Method Discovery | ✅ | ✅ | ✅ | +| Directory Publishing | ✅ | ✅ | ✅ | +| Noise Protocol Payments | ✅ | ✅ | N/A | +| Subscriptions | ✅ | ✅ | N/A | +| Auto-Pay Rules | ✅ | ✅ | N/A | +| Spending Limits | ✅ | ✅ | N/A | +| Smart Checkout | ⚠️ Not integrated (Bitkit) | ⚠️ Not integrated (Bitkit) | N/A | +| Cross-App Key Sharing | ✅ | ✅ | ✅ | + +### Current Status + +| Component | Status | Notes | +|-----------|--------|-------| +| `paykit-lib` | ✅ Production-Ready | Core protocol library | +| `paykit-interactive` | ✅ Production-Ready | Noise payments | +| `paykit-subscriptions` | ✅ Production-Ready | Recurring payments | +| `paykit-mobile` | ✅ Production-Ready | FFI bindings | +| Bitkit iOS Integration | ⚠️ Verification Required | Core features + security hardening complete | +| Bitkit Android Integration | ⚠️ Verification Required | Core features + security hardening complete | +| Ring Integration | ⚠️ Verification Required | Secure handoff + signing implemented | + +### Pre-Production Verification Checklist + +Before deploying to production, verify end-to-end: +- [ ] Secure handoff flow works (no secrets in URLs) +- [ ] iOS push relay Ed25519 signing completes successfully +- [ ] Android push relay Ed25519 signing completes successfully +- [ ] Key rotation from epoch 0 to epoch 1 succeeds +- [ ] Cache miss recovery auto-requests from Ring +- [ ] Cross-device authentication via QR works +- [ ] All deep link callbacks handled correctly +- [ ] Session persistence survives app restart +- [ ] Type-safe HomeserverURL prevents pubkey/URL confusion + +### Review Lens (for architecture + assumptions) + +This section is meant to help the Bitkit dev team review the project at a high level (challenge assumptions, validate decisions, and spot missing production wiring) before diving into implementation details. + +#### What to read first (recommended order) + +1. **This Section 1. Executive Summary** (what exists + what still needs verification) +2. **Section 17. Architectural Hardening** (Phases 1–4: security + reliability changes) +3. **[SECURITY_ARCHITECTURE.md](SECURITY_ARCHITECTURE.md)** (threat model, attack surface, security properties) +4. **[PUSH_RELAY_DESIGN.md](PUSH_RELAY_DESIGN.md)** (push relay API + auth model) +5. **Section 16. Production Implementation Checklist** (what production must wire up) + +#### Key architectural decisions (with tradeoffs) + +| Decision | What we did | Tradeoff / what to challenge | +|---|---|---| +| Ring-only identity | Ed25519 master secret never leaves Ring; Bitkit consumes sessions + derived X25519 keys | Requires Ring installation (or cross-device flow) for initial provisioning and for signing requests | +| Secure handoff | Ring writes handoff JSON at an unguessable homeserver path; Bitkit fetches via `request_id` | Payload is **not encrypted at rest**; security relies on unguessability + TTL + TLS + deletion after fetch | +| Push relay vs public directory tokens | Push tokens are registered to a relay; wake requests require Ed25519 signatures | Adds backend dependency; requires careful lifecycle wiring for token rotation + session replacement | +| Type-safe identifiers | Introduced `HomeserverURL`, `HomeserverPubkey`, `OwnerPubkey`, `SessionSecret` | Requires discipline to avoid reintroducing raw strings at boundaries | +| Key rotation model | Epoch-based X25519 keypairs (epoch 0 + epoch 1) cached locally; rotation is manual-triggered | No automatic cadence; requires product decision on rotation triggers and migration path | + +#### Invariants (things the system assumes are true) + +- **No secrets in callback URLs** for paykit setup (secure handoff only) +- **Sessions authenticate via cookie**: `Cookie: session=` on authenticated homeserver requests +- **Ring is the only signer**: Ed25519 signatures used for push relay auth are produced by Ring +- **Handoff lifecycle**: short TTL + Bitkit deletes after fetch (defense-in-depth) + +#### Review prompts (what to scrutinize) + +- **Security**: + - Are we comfortable with “public read + unguessable path” for handoff payloads, or do we require at-rest encryption / authenticated read? + - Are callback schemes and deep link handlers hardened against spoofing and confused-deputy issues? + - Are we leaking any secrets via logs, analytics, crash reports, or OS-level deep link telemetry? +- **Reliability**: + - What is the expected behavior when Ring is unavailable, the relay is unavailable, or the homeserver is slow/unreachable? + - Do session expiry/refresh paths cover all real session types we rely on? + - Do background workers/tasks align with iOS/Android OS constraints for wake + polling? +- **Maintainability**: + - Are boundaries clear (`UI → ViewModel → Repository/Service → FFI/SDK`) and consistent across both platforms? + - Are we duplicating HTTP/signing/session logic in multiple services that should be consolidated (see Future Work)? +- **Product/UX**: + - What user-facing flows exist when signing is required (Ring prompts), and are those flows acceptable? + - What is the plan for user education and failure recovery (Ring not installed, revoked capabilities, etc.)? + +--- + +## 2. Architecture Overview + +### Component Diagram + +```mermaid +flowchart TB + subgraph bitkit [Bitkit App] + UI[SwiftUI / Compose UI] + VM[ViewModels] + SVC[Services Layer] + STORE[Secure Storage] + end + + subgraph ring [Pubky Ring] + RING_UI[Ring UI] + RING_KEYS[Key Manager] + RING_SESSION[Session Manager] + end + + subgraph paykit [paykit-mobile FFI] + FFI[UniFFI Bindings] + CLIENT[PaykitClient] + TRANSPORT[Transport Adapters] + end + + subgraph rust [Rust Core] + LIB[paykit-lib] + INTER[paykit-interactive] + SUBS[paykit-subscriptions] + end + + subgraph external [External Services] + PUBKY[Pubky Homeserver] + LN[Lightning Network] + BTC[Bitcoin Network] + end + + UI --> VM --> SVC --> STORE + SVC --> FFI + FFI --> CLIENT --> LIB + CLIENT --> INTER + CLIENT --> SUBS + + RING_SESSION <-.-> SVC + RING_KEYS --> TRANSPORT + + LIB --> PUBKY + INTER --> PUBKY + SVC --> LN + SVC --> BTC +``` + +### Key Architecture: "Cold Pkarr, Hot Noise" + +This architecture separates key responsibilities: + +| Key Type | Purpose | Storage | Rotation | +|----------|---------|---------|----------| +| **Ed25519 (pkarr)** | Identity, signatures | Ring (cold) | Rarely | +| **X25519 (noise)** | Session encryption | Bitkit (hot) | Per-session | + +**Flow:** +1. Ring holds the master Ed25519 identity key ("cold") +2. Bitkit derives X25519 session keys via HKDF ("hot") +3. Noise channels use X25519 for encryption +4. Signatures for subscriptions use Ed25519 from Ring + +### Data Flow: Payment Discovery + +```mermaid +sequenceDiagram + participant User as User (Bitkit) + participant Paykit as Paykit FFI + participant Ring as Pubky Ring + participant HS as Pubky Homeserver + + User->>Ring: Request session + Ring-->>User: Signed session token + User->>Paykit: Initialize with session + Paykit->>HS: Publish payment methods + Note over HS: /pub/paykit.app/v0/{methodId} + + User->>Paykit: Discover peer methods + Paykit->>HS: GET /pub/paykit.app/v0/ + HS-->>Paykit: Available methods + Paykit-->>User: SupportedPayments +``` + +--- + +## 3. Prerequisites + +### Development Environment + +| Tool | Required Version | Purpose | +|------|------------------|---------| +| Rust | 1.70+ (via Rustup, NOT Homebrew) | Build paykit-rs | +| UniFFI | 0.25+ | Generate FFI bindings | +| Xcode | 14+ | iOS build | +| Swift | 5.5+ | iOS bindings | +| Android Studio | Latest | Android build | +| Kotlin | 1.8+ | Android bindings | +| Android NDK | r25+ | Native library compilation | + +### ⚠️ CRITICAL: Rust Installation + +**DO NOT use Homebrew Rust.** WASM targets and cross-compilation require Rustup. + +```bash +# If you have Homebrew Rust, remove it first +brew uninstall rust + +# Install Rustup +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh + +# Add targets +rustup target add aarch64-apple-ios +rustup target add aarch64-apple-ios-sim +rustup target add x86_64-apple-ios +rustup target add aarch64-linux-android +rustup target add armv7-linux-androideabi +rustup target add i686-linux-android +rustup target add x86_64-linux-android +rustup target add wasm32-unknown-unknown +``` + +### Repository Setup + +Clone all required repositories (use your internal remotes/forks as appropriate): + +```bash +mkdir -p ~/vibes-dev && cd ~/vibes-dev + +# Core Paykit +git clone https://github.com/synonymdev/paykit-rs.git + +# Mobile apps +git clone https://github.com/synonymdev/bitkit-ios.git +git clone https://github.com/synonymdev/bitkit-android.git + +# Pubky ecosystem +git clone https://github.com/pubky/pubky-ring.git +git clone https://github.com/pubky/pubky-noise.git +git clone https://github.com/pubky/pubky-core.git +``` + +--- + +## 4. Building paykit-rs + +### Step 1: Build the Core Library + +```bash +cd ~/vibes-dev/paykit-rs + +# Build release for current platform +cargo build --release -p paykit-mobile + +# Verify build artifacts +ls -la target/release/libpaykit_mobile.* +# Should see: libpaykit_mobile.dylib (macOS) or .so (Linux) +``` + +### Step 2: Generate FFI Bindings + +```bash +# Install uniffi-bindgen if not installed (must match the UniFFI version in paykit-mobile) +cargo install uniffi-bindgen-cli@0.25 + +# Generate bindings using the repo script (preferred) +cd paykit-mobile +./generate-bindings.sh + +# Outputs (host platform): +# - paykit-mobile/swift/generated/PaykitMobile.swift + PaykitMobileFFI.h + PaykitMobileFFI.modulemap +# - paykit-mobile/kotlin/generated/paykit_mobile.kt +``` + +### Step 3: Build for iOS (All Architectures) + +```bash +cd paykit-mobile + +# Build and create an XCFramework (this is what Bitkit iOS consumes) +./build-ios.sh --framework + +# Outputs: +# - paykit-mobile/ios-demo/PaykitDemo/PaykitDemo/Frameworks/PaykitMobile.xcframework +# - headers/modulemap inside the XCFramework from paykit-mobile/swift/generated/ +``` + +### Step 3.1: Copy XCFramework into Bitkit iOS + +```bash +# Copy PaykitMobile.xcframework into Bitkit iOS integration frameworks directory +cp -R \ + ../paykit-mobile/ios-demo/PaykitDemo/PaykitDemo/Frameworks/PaykitMobile.xcframework \ + ../../bitkit-ios/Bitkit/PaykitIntegration/Frameworks/ +``` + +Bitkit iOS currently treats this as an interim approach (copied binary). See: +- `bitkit-ios/Bitkit/PaykitIntegration/Frameworks/FRAMEWORKS_README.md` + +### Step 4: Build for Android (All ABIs) + +```bash +# Set NDK path +export ANDROID_NDK_HOME=$HOME/Library/Android/sdk/ndk/25.2.9519653 + +# Run the Android build script +./build-android.sh + +# This creates libraries for each ABI: +# - jniLibs/arm64-v8a/libpaykit_mobile.so +# - jniLibs/armeabi-v7a/libpaykit_mobile.so +# - jniLibs/x86/libpaykit_mobile.so +# - jniLibs/x86_64/libpaykit_mobile.so +``` + +--- + +## 4.5 Building pubky-noise (Required for Noise Payments) + +Bitkit and Ring both require `pubky-noise` for encrypted channels. **pubky-noise is a separate repository** from paykit-rs. + +### Step 1: Build for iOS + +```bash +cd ~/vibes-dev/pubky-noise + +# Build XCFramework (device + simulator) +./build-ios.sh + +# Outputs: +# - platforms/ios/PubkyNoise.xcframework/ +# - generated-swift/PubkyNoise.swift (UniFFI bindings) +# - generated-swift/PubkyNoiseFFI.h + .modulemap +``` + +### Step 2: Build for Android + +```bash +cd ~/vibes-dev/pubky-noise + +# Ensure NDK is set +export ANDROID_NDK_HOME=$HOME/Library/Android/sdk/ndk/25.2.9519653 + +./build-android.sh + +# Outputs: +# - platforms/android/src/main/jniLibs/arm64-v8a/libpubky_noise.so +# - platforms/android/src/main/jniLibs/x86_64/libpubky_noise.so +# - generated-kotlin/com/pubky/noise/pubky_noise.kt +``` + +### Step 3: Copy to Target Projects + +**For Bitkit iOS:** +```bash +cp -R pubky-noise/platforms/ios/PubkyNoise.xcframework \ + bitkit-ios/Bitkit/PaykitIntegration/Frameworks/ + +cp pubky-noise/generated-swift/PubkyNoise.swift \ + bitkit-ios/Bitkit/PaykitIntegration/FFI/ +``` + +**For Bitkit Android:** +```bash +cp pubky-noise/platforms/android/src/main/jniLibs/arm64-v8a/libpubky_noise.so \ + bitkit-android/app/src/main/jniLibs/arm64-v8a/ + +cp pubky-noise/platforms/android/src/main/jniLibs/x86_64/libpubky_noise.so \ + bitkit-android/app/src/main/jniLibs/x86_64/ + +cp pubky-noise/generated-kotlin/com/pubky/noise/pubky_noise.kt \ + bitkit-android/app/src/main/java/com/pubky/noise/ +``` + +**For Pubky Ring iOS:** +```bash +cp -R pubky-noise/platforms/ios/PubkyNoise.xcframework \ + pubky-ring/ios/ + +cp pubky-noise/generated-swift/PubkyNoise.swift \ + pubky-ring/ios/pubkyring/ +``` + +**For Pubky Ring Android:** +```bash +cp pubky-noise/platforms/android/src/main/jniLibs/arm64-v8a/libpubky_noise.so \ + pubky-ring/android/app/src/main/jniLibs/arm64-v8a/ + +cp pubky-noise/generated-kotlin/com/pubky/noise/pubky_noise.kt \ + pubky-ring/android/app/src/main/java/com/pubky/noise/ +``` + +### pubky-noise Version Compatibility + +| Component | Minimum Version | Notes | +|-----------|-----------------|-------| +| pubky-noise | 1.0.0+ | Has `deriveDeviceKey` throwing variant | +| Bitkit iOS | Swift 5.5+ | Uses XCFramework | +| Bitkit Android | Kotlin 1.8+ | Uses JNI .so | +| Ring iOS | Swift 5.5+ | Uses XCFramework via CocoaPods | +| Ring Android | Kotlin 1.8+ | Uses JNI .so | + +**Key API (pubky-noise 1.0+):** + +```rust +// From pubky-noise Rust API (what UniFFI exposes) +pub fn derive_device_key( + seed: &[u8], // 32-byte Ed25519 seed + device_id: &[u8], // Arbitrary device identifier + epoch: u32 // Rotation epoch (0, 1, 2...) +) -> Result<[u8; 32], NoiseError>; + +pub fn public_key_from_secret(secret: &[u8]) -> [u8; 32]; +``` + +--- + +## 5. iOS Integration + +### Step 1: Add Framework to Xcode + +1. **Copy files to project:** + ``` + Bitkit/ + └── PaykitIntegration/ + ├── FFI/ + │ ├── PaykitMobile.swift # Generated UniFFI Swift bindings + │ ├── PaykitMobileFFI.h # UniFFI-generated C header + │ └── PaykitMobileFFI.modulemap # Module map used by the XCFramework + ├── Frameworks/ + │ ├── PaykitMobile.xcframework # From paykit-rs/paykit-mobile (build-ios.sh --framework) + │ └── PubkyNoise.xcframework # From pubky-noise (iOS build script) + └── Services/ + ├── PaykitManager.swift + ├── DirectoryService.swift + └── NoisePaymentService.swift + ``` + +2. **Configure Xcode project:** + - Add `Bitkit/PaykitIntegration/Frameworks/PaykitMobile.xcframework` to the project. + - Ensure the XCFramework is linked in the Bitkit target under: + - **General** → **Frameworks, Libraries, and Embedded Content** + - Do not manually add `-lpaykit_mobile` when using the XCFramework approach. + - Keep the UniFFI generated Swift file in the app target (Bitkit imports PaykitMobile types through `Bitkit/PaykitIntegration/FFI/PaykitMobile.swift`). + +### Step 2: Initialize PaykitManager + +```swift +// This is the real Bitkit integration pattern: +// - Initialize PaykitClient with the correct network +// - Restore Pubky sessions from Keychain +// - Configure Pubky SDK +// - Register Bitcoin + Lightning executors so Paykit can execute payments +// +// Reference implementation: +// - bitkit-ios/Bitkit/PaykitIntegration/PaykitManager.swift +// - bitkit-ios/Bitkit/PaykitIntegration/PaykitIntegrationHelper.swift + +do { + try PaykitManager.shared.initialize() + try PaykitManager.shared.registerExecutors() +} catch { + Logger.error("Paykit setup failed: \(error)", context: "Paykit") +} +``` + +### Step 3: Implement Transport Callbacks + +Bitkit does not implement a bespoke URLSession “transport callback” in the app layer. Instead it uses the UniFFI callback-based transports directly, wired through `DirectoryService`: + +- `bitkit-ios/Bitkit/PaykitIntegration/Services/DirectoryService.swift` +- `bitkit-ios/Bitkit/PaykitIntegration/Services/PubkyStorageAdapter.swift` + +The real pattern is: + +1. Create a `PubkyUnauthenticatedStorageAdapter` (read-only) and wrap it: + - `UnauthenticatedTransportFfi.fromCallback(callback: adapter)` +2. Create a `PubkyAuthenticatedStorageAdapter` (write) and wrap it: + - `AuthenticatedTransportFfi.fromCallback(callback: adapter, ownerPubkey: )` +3. Pass these transports into Paykit directory operations. + +In Bitkit iOS, the session secret is transported as a cookie header: `Cookie: session=`. + +### Step 4: Register Deep Links + +In `Info.plist`, add URL schemes: + +```xml +CFBundleURLTypes + + + CFBundleURLSchemes + + bitkit + paykit + + + +``` + +Handle in the Bitkit deep link layer. The reference implementation handles **payment request deep links** in `bitkit-ios/Bitkit/MainNavView.swift`. + +Supported formats: +- `paykit://payment-request?requestId=&from=` +- `bitkit://payment-request?requestId=&from=` + +The publish-side creates a deep link like: +- `bitkit://payment-request?requestId=&from=` + +Important: Bitkit currently uses **payment requests + autopay evaluation**. Do not assume a “smart checkout” URI like `paykit:///pay?amount=&memo=` exists in Bitkit. + +```swift +// Reference implementation: bitkit-ios/Bitkit/MainNavView.swift +// Supported formats: +// - paykit://payment-request?requestId=&from= +// - bitkit://payment-request?requestId=&from= + +private func handleIncomingURL(_ url: URL) { + if url.scheme == "paykit" || (url.scheme == "bitkit" && url.host == "payment-request") { + Task { + await handlePaymentRequestDeepLink(url: url) + } + return + } + + // Handle other Bitkit deep links (bitcoin:, lightning:, lnurl*, internal routes) +} + +private func handlePaymentRequestDeepLink(url: URL) async { + guard + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let queryItems = components.queryItems, + let requestId = queryItems.first(where: { $0.name == "requestId" })?.value, + let fromPubkey = queryItems.first(where: { $0.name == "from" })?.value + else { + // Show error to user: invalid deep link + return + } + + // Validate Pubky sender pubkey (z-base-32, 52 chars) + if !isValidZBase32Pubkey(fromPubkey) { + // Show error to user: invalid sender pubkey + return + } + + // Ensure Paykit is ready (this can fail if Ring isn't connected) + if !PaykitManager.shared.isInitialized { + do { + try PaykitManager.shared.initialize() + try PaykitManager.shared.registerExecutors() + } catch { + // “Please connect to Pubky Ring first” + return + } + } + + guard let paykitClient = PaykitManager.shared.client else { return } + + // Policy: autopay evaluation lives in app code + let autoPayViewModel = await AutoPayViewModel() + + let paymentRequestService = PaymentRequestService( + paykitClient: paykitClient, + autopayEvaluator: autoPayViewModel, + paymentRequestStorage: PaymentRequestStorage(), + directoryService: DirectoryService.shared + ) + + paymentRequestService.handleIncomingRequest(requestId: requestId, fromPubkey: fromPubkey) { result in + Task { @MainActor in + // Handle: + // - autoPaid(paymentResult) + // - needsApproval(request) + // - denied(reason) + // - error(error) + } + } +} + +private func isValidZBase32Pubkey(_ pubkey: String) -> Bool { + // z-base-32 encoded Ed25519 keys are 52 characters + // Valid charset: ybndrfg8ejkmcpqxot1uwisza345h769 + let validCharset = CharacterSet(charactersIn: "ybndrfg8ejkmcpqxot1uwisza345h769") + return pubkey.count == 52 && pubkey.rangeOfCharacter(from: validCharset.inverted) == nil +} +``` + +### Step 5: Implement Keychain Storage + +```swift +// PaykitKeychainStorage.swift +import Security + +class PaykitKeychainStorage { + private let service = "to.bitkit.paykit" + + func save(key: String, data: Data) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock + ] + + SecItemDelete(query as CFDictionary) + let status = SecItemAdd(query as CFDictionary, nil) + guard status == errSecSuccess else { + throw KeychainError.saveFailed(status) + } + } + + func load(key: String) throws -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecReturnData as String: true + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess else { + throw KeychainError.loadFailed(status) + } + return result as? Data + } +} +``` + +--- + +## 6. Android Integration + +### Step 1: Add JNI Libraries + +1. **Copy SO files:** + ``` + app/src/main/jniLibs/ + ├── arm64-v8a/ + │ └── libpaykit_mobile.so + ├── armeabi-v7a/ + │ └── libpaykit_mobile.so + ├── x86/ + │ └── libpaykit_mobile.so + └── x86_64/ + └── libpaykit_mobile.so + ``` + +2. **Copy Kotlin bindings:** + ``` + app/src/main/java/uniffi/paykit_mobile/ + └── paykit_mobile.kt + ``` + +### Step 2: Configure Gradle + +```kotlin +// app/build.gradle.kts +android { + defaultConfig { + ndk { + abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64") + } + } + + sourceSets { + getByName("main") { + jniLibs.srcDirs("src/main/jniLibs") + } + } +} + +dependencies { + implementation("net.java.dev.jna:jna:5.13.0@aar") +} +``` + +### Step 3: Initialize PaykitManager + +```kotlin +// PaykitManager.kt +@Singleton +class PaykitManager @Inject constructor( + @ApplicationContext private val context: Context +) { + private var client: PaykitClient? = null + + val isReady: Boolean + get() = client != null + + suspend fun initialize() = withContext(Dispatchers.IO) { + try { + // Load native library + System.loadLibrary("paykit_mobile") + client = PaykitClient() + } catch (e: Exception) { + Logger.error("Paykit init failed", e = e, context = TAG) + } + } + + companion object { + private const val TAG = "PaykitManager" + } +} +``` + +### Step 4: Implement Encrypted Storage + +```kotlin +// PaykitSecureStorage.kt +class PaykitSecureStorage(context: Context) { + private val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + private val prefs = EncryptedSharedPreferences.create( + context, + "paykit_secure_prefs", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + + fun save(key: String, value: String) { + prefs.edit().putString(key, value).apply() + } + + fun load(key: String): String? { + return prefs.getString(key, null) + } +} +``` + +### Step 5: Register Deep Links + +```xml + + + + + + + + + + + + + +``` + +Handle in ViewModel (reference implementation): `bitkit-android/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt` + +```kotlin +// AppViewModel.kt +fun handleDeepLink(uri: Uri) { + when (uri.scheme) { + "paykit" -> { + // paykit://payment-request?requestId=&from= + // Delegate to payment request handler (see handlePaymentRequestDeepLink) + } + } +} +``` + +--- + +## 7. Pubky Ring Integration + +### Overview + +Pubky Ring is a separate React Native app that manages identity keys. Bitkit communicates with Ring to: +1. Get the user's Pubky identity (Ed25519 public key) +2. Derive X25519 noise keypairs for encrypted channels +3. Establish authenticated sessions with homeservers +4. Request profile and follows data + +**Repository Structure (Ring):** +- `pubky-ring/` - React Native app +- `pubky-ring/ios/pubkyring/PubkyNoiseModule.swift` - iOS native module for pubky-noise +- `pubky-ring/android/app/src/main/java/to/pubkyring/PubkyNoiseModule.kt` - Android native module +- `pubky-ring/src/utils/actions/paykitConnectAction.ts` - Paykit setup handler +- `pubky-ring/src/utils/inputParser.ts` - Deep link parsing +- `pubky-ring/src/utils/inputRouter.ts` - Action routing + +### 7.1 Native Module Architecture (pubky-noise in Ring) + +Ring embeds `pubky-noise` as a native module (not a React Native npm package): + +**iOS Integration:** +``` +ios/PubkyNoise.xcframework/ <- Pre-built static library +ios/pubkyring/PubkyNoise.swift <- UniFFI-generated Swift bindings +ios/pubkyring/PubkyNoiseModule.swift <- React Native bridge +ios/pubkyring/PubkyNoiseModule.m <- Objective-C declarations +``` + +**Android Integration:** +``` +android/app/src/main/jniLibs/arm64-v8a/libpubky_noise.so <- Native library +android/app/src/main/java/com/pubky/noise/pubky_noise.kt <- UniFFI-generated Kotlin bindings +android/app/src/main/java/to/pubkyring/PubkyNoiseModule.kt <- React Native bridge +``` + +**Key Native Module Methods (exposed to JavaScript):** + +```swift +// PubkyNoiseModule.swift (iOS example) + +/// Derive X25519 keypair from Ed25519 seed using pubky-noise KDF +@objc(deriveX25519ForDeviceEpoch:deviceIdHex:epoch:resolver:rejecter:) +func deriveX25519ForDeviceEpoch( + _ seedHex: String, // Ed25519 secret key (64 hex chars) + deviceIdHex: String, // Device ID (hex string) + epoch: UInt32, // Epoch for key rotation (0, 1, 2...) + resolve: RCTPromiseResolveBlock, + reject: RCTPromiseRejectBlock +) +// Returns: { secretKey: string, publicKey: string } (hex) + +/// Create a Noise manager for client-side connections +@objc(createClientManager:clientKid:deviceIdHex:configType:resolver:rejecter:) +func createClientManager(...) +// Returns: { managerId: string } + +/// Initiate IK handshake with server +@objc(initiateConnection:serverPkHex:hint:resolver:rejecter:) +func initiateConnection(...) +// Returns: { sessionId: string, firstMessage: string (hex) } + +/// Complete handshake with server response +@objc(completeConnection:serverResponse:resolver:rejecter:) +func completeConnection(...) +// Returns: sessionId (string) + +/// Encrypt/decrypt with established session +@objc(encrypt:plaintext:resolver:rejecter:) +@objc(decrypt:ciphertext:resolver:rejecter:) +``` + +**How `deriveDeviceKey` works (from pubky-noise):** + +```rust +// pubky-noise/src/kdf.rs (conceptual) +pub fn derive_device_key( + ed25519_seed: [u8; 32], // Master Ed25519 seed + device_id: &[u8], // Unique device identifier + epoch: u32 // Rotation epoch +) -> [u8; 32] { + // HKDF-SHA256 derivation + let ikm = ed25519_seed; + let salt = device_id; + let info = format!("noise-device-key-{}", epoch); + + hkdf_sha256(ikm, salt, info.as_bytes()) +} +``` + +### 7.2 Paykit Connect Action (Ring-side implementation) + +When Bitkit calls `pubkyring://paykit-connect?deviceId=...&callback=...`, Ring processes it via: + +**File:** `pubky-ring/src/utils/actions/paykitConnectAction.ts` + +**Note**: The header comment in `paykitConnectAction.ts` is stale. The current implementation **always** uses secure handoff, and the handoff payload is **not encrypted at rest** (security relies on the unguessable path + TTL + TLS + deletion after fetch). + +```typescript +// Current implementation uses SECURE HANDOFF (no secrets in URL) +export const handlePaykitConnectAction = async ( + data: PaykitConnectActionData, + context: ActionContext +): Promise> => { + const { pubky, dispatch } = context; + const { deviceId, callback, includeEpoch1 = true } = data.params; + + // Step 1: Sign in to homeserver (gets session) + const signInResult = await signInToHomeserver({ pubky, dispatch }); + const sessionInfo = signInResult.value; + + // Step 2: Get Ed25519 secret key from secure storage + const { secretKey: ed25519SecretKey } = await getPubkySecretKey(pubky); + + // Step 3: Derive X25519 keypairs via native module + const keypair0 = await deriveX25519Keypair(ed25519SecretKey, deviceId, 0); + const keypair1 = includeEpoch1 + ? await deriveX25519Keypair(ed25519SecretKey, deviceId, 1) + : null; + + // Step 4: Store payload on homeserver at unguessable path + const requestId = generateRequestId(); // 256-bit random + const handoffPath = `pubky://${pubky}/pub/paykit.app/v0/handoff/${requestId}`; + + const payload = { + version: 1, + pubky: sessionInfo.pubky, + session_secret: sessionInfo.session_secret, + capabilities: sessionInfo.capabilities, + device_id: deviceId, + noise_keypairs: [ + { epoch: 0, public_key: keypair0.publicKey, secret_key: keypair0.secretKey }, + keypair1 && { epoch: 1, public_key: keypair1.publicKey, secret_key: keypair1.secretKey }, + ].filter(Boolean), + created_at: Date.now(), + expires_at: Date.now() + 5 * 60 * 1000, // 5 minutes + }; + + await put(handoffPath, payload, ed25519SecretKey); + + // Step 5: Return to Bitkit with ONLY request_id (no secrets!) + const callbackUrl = buildCallbackUrl(callback, { + mode: 'secure_handoff', + pubky: sessionInfo.pubky, + request_id: requestId, + }); + + await Linking.openURL(callbackUrl); +}; +``` + +**Callback URL Format (Secure Handoff)**: +``` +bitkit://paykit-setup?mode=secure_handoff&pubky=&request_id=<256bit_hex> +``` + +**Bitkit then**: +1. Fetches payload from `pubky:///pub/paykit.app/v0/handoff/` +2. Parses session and noise keypairs from JSON +3. Deletes the handoff file immediately (iOS + Android) to minimize exposure window +4. Caches session and keypairs locally + +### 7.3 Bitkit-side Session and Key Handling + +**PubkySDKService - Direct homeserver operations via pubky-core-ffi:** + +Bitkit uses `PubkySDKService` (not just Ring) for direct homeserver operations: +- iOS: `bitkit-ios/Bitkit/PaykitIntegration/Services/PubkySDKService.swift` +- Android: `bitkit-android/app/src/main/java/to/bitkit/paykit/services/PubkySDKService.kt` + +```swift +// iOS PubkySDKService - importing a session from Ring +public func importSession(pubkey: String, sessionSecret: String) throws -> BitkitCore.PubkySessionInfo { + ensureInitialized() + // Uses BitkitCore FFI (which wraps pubky-core) to import the session + let session = try BitkitCore.pubkyImportSession(pubkey: pubkey, sessionSecret: sessionSecret) + Logger.info("Imported session for \(session.pubkey.prefix(12))...", context: "PubkySDKService") + return session +} + +// Direct homeserver operations (after session is imported) +public func sessionPut(pubkey: String, path: String, content: Data) async throws { + try await pubkySessionPut(pubkey: pubkey, path: path, content: content) +} + +public func sessionGet(pubkey: String, path: String) async throws -> Data { + return try await pubkySessionGet(pubkey: pubkey, path: path) +} + +public func publicGet(uri: String) async throws -> Data { + ensureInitialized() + return try await BitkitCore.pubkyPublicGet(uri: uri) +} +``` + +**NoiseKeyCache - Persistent noise key storage:** + +Bitkit caches noise keys to avoid repeated Ring requests: +- iOS: `PaykitIntegration/Storage/NoiseKeyCache.swift` +- Android: `paykit/storage/NoiseKeyCache.kt` + +```swift +// iOS NoiseKeyCache +class NoiseKeyCache { + static let shared = NoiseKeyCache() + private let keychain = PaykitKeychainStorage() + + func setKey(_ keyData: Data, deviceId: String, epoch: UInt32) { + let key = "noise.key.\(deviceId).\(epoch)" + keychain.set(key: key, value: keyData) + } + + func getKey(deviceId: String, epoch: UInt32) -> Data? { + let key = "noise.key.\(deviceId).\(epoch)" + return keychain.get(key: key) + } +} +``` + +**Session Refresh - Background lifecycle management:** + +Bitkit implements background session refresh to keep sessions alive: +- iOS: `SessionRefreshService` using `BGAppRefreshTask` +- Android: `SessionRefreshWorker` using WorkManager + +```swift +// iOS - Register in AppDelegate/AppScene +SessionRefreshService.shared.registerBackgroundTask() + +// Schedule hourly refresh +SessionRefreshService.shared.scheduleSessionRefresh() + +// Manual trigger (foreground) +await SessionRefreshService.shared.refreshSessionsNow() +``` + +```kotlin +// Android - Schedule from Application or MainActivity +SessionRefreshWorker.schedule(context) + +// Worker runs every hour via WorkManager +// Calls pubkySDKService.refreshExpiringSessions() +``` + +**Session expiration handling (Android PubkySDKService):** + +```kotlin +fun isSessionExpired(session: PubkyCoreSession, bufferSeconds: Long = 300): Boolean { + val expiresAt = session.expiresAt ?: return false + val bufferMs = bufferSeconds * 1000 + return System.currentTimeMillis() + bufferMs >= expiresAt +} + +suspend fun refreshExpiringSessions() { + sessionMutex.withLock { + sessionCache.values.filter { isSessionExpired(it, 600) }.forEach { session -> + try { + revalidateSession(session.sessionSecret) + } catch (e: Exception) { + Logger.warn("Failed to refresh session ${session.pubkey.take(12)}", e, TAG) + } + } + } +} +``` + +### Cross-App Communication Protocol (Reference Implementation) + +Bitkit iOS implements a full bridge with same-device and cross-device auth: +- `bitkit-ios/Bitkit/PaykitIntegration/Services/PubkyRingBridge.swift` + +Bitkit Android implements the same flows: +- `bitkit-android/app/src/main/java/to/bitkit/paykit/services/PubkyRingBridge.kt` + +#### Callback paths (must match in Bitkit and Ring) + +Bitkit expects these callback paths on its own scheme (`bitkit://`): +- `bitkit://paykit-session` +- `bitkit://paykit-keypair` +- `bitkit://paykit-profile` +- `bitkit://paykit-follows` +- `bitkit://paykit-cross-session` +- `bitkit://paykit-setup` (preferred: session + noise keys in one request) + +#### Same-device flow (preferred when Ring is installed) + +1. Bitkit launches Ring with a callback: + - `pubkyring://session?callback=` +2. Ring prompts the user to select an identity, signs in to the homeserver, then calls back to Bitkit: + - `bitkit://paykit-session?pubky=&session_secret=&capabilities=` + +#### Combined setup flow: session + noise keys (preferred for Paykit) + +Bitkit uses `requestPaykitSetup()` which launches: +- `pubkyring://paykit-connect?deviceId=&callback=` + +Why this matters: +- It minimizes user context switching (one Ring interaction). +- It returns **both epoch 0 and epoch 1** Noise keypairs for rotation. +- Bitkit caches/persists the Noise secret keys locally so Paykit can operate even if Ring is unavailable later. + +#### Cross-device flow (Ring installed on a different device) + +Bitkit generates a web URL for QR / link: +- `https://pubky.app/auth?request_id=&callback_scheme=bitkit&app_name=Bitkit&relay_url=` + +Ring completes auth and posts the session to the relay; Bitkit polls the relay for up to 5 minutes: +- iOS: `PubkyRingBridge.pollForCrossDeviceSession(requestId:timeout:)` +- Android: `PubkyRingBridge.pollForCrossDeviceSession(requestId, timeoutMs)` + +Relay default: +- iOS default: `https://relay.pubky.app/sessions` (override with `PUBKY_RELAY_URL`) +- Android default: `https://relay.pubky.app/sessions` (override with `-DPUBKY_RELAY_URL=`) + +### Cross-App Communication (Android) + +Android uses **deep links** (not Intent actions) for Ring communication: + +```kotlin +// PubkyRingBridge.kt - Deep link approach (excerpt; see full file for all callbacks) +@Singleton +class PubkyRingBridge @Inject constructor( + private val keychainStorage: to.bitkit.paykit.storage.PaykitKeychainStorage, + private val noiseKeyCache: NoiseKeyCache, + private val pubkyStorageAdapter: PubkyStorageAdapter, +) { + companion object { + private const val PUBKY_RING_SCHEME = "pubkyring" + private const val BITKIT_SCHEME = "bitkit" + private const val CALLBACK_PATH_SIGNATURE_RESULT = "signature-result" + } + + // Request Ed25519 signature from Ring + suspend fun requestSignature(context: Context, message: String): String = + suspendCancellableCoroutine { continuation -> + val callbackUrl = "$BITKIT_SCHEME://$CALLBACK_PATH_SIGNATURE_RESULT" + val encodedMessage = URLEncoder.encode(message, "UTF-8") + val encodedCallback = URLEncoder.encode(callbackUrl, "UTF-8") + + // Deep link to Ring + val requestUrl = "$PUBKY_RING_SCHEME://sign-message?message=$encodedMessage&callback=$encodedCallback" + + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(requestUrl)) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + + // Ring returns via: bitkit://signature-result?signature=&pubkey= + pendingSignatureContinuation = continuation + } + + // Handle callback from Ring + fun handleCallback(uri: Uri): Boolean { + if (uri.scheme != BITKIT_SCHEME) return false + + return when (uri.host) { + CALLBACK_PATH_SIGNATURE_RESULT -> handleSignatureCallback(uri) + // ... other callback handlers + else -> false + } + } +} +``` + +**Ring Deep Link Formats**: +| Action | Deep Link | Callback | +|--------|-----------|----------| +| Sign message | `pubkyring://sign-message?message={msg}&callback={url}` | `bitkit://signature-result?signature={hex}&pubkey={z32}` | +| Paykit setup | `pubkyring://paykit-connect?deviceId={id}&callback={url}` | `bitkit://paykit-setup?mode=secure_handoff&pubky={z32}&request_id={hex}` | +| Get session | `pubkyring://session?callback={url}` | `bitkit://paykit-session?pubky={z32}&session_secret={secret}` | + +### Session material in Bitkit (what Bitkit actually persists) + +Bitkit does not use a JSON bearer token model here. The reference implementation uses: +- `session.pubkey`: 52-char z-base-32 pubkey +- `session.sessionSecret`: opaque session secret string (used as cookie value) + +The storage adapters attach the session to authenticated requests via: +- `Cookie: session=` + +Reference: +- iOS: `PubkyAuthenticatedStorageAdapter` in `bitkit-ios/Bitkit/PaykitIntegration/Services/PubkyStorageAdapter.swift` +- Android: `PubkyAuthenticatedStorageAdapter` in `bitkit-android/app/src/main/java/to/bitkit/paykit/services/PubkyStorageAdapter.kt` + +--- + +## 8. Feature Implementation Guide + +### 8.1 Payment Method Discovery + +**Publishing your payment methods:** + +```swift +// Publish onchain address +try await paykitClient.publishPaymentMethod( + methodId: "onchain", + endpoint: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh" +) + +// Publish Lightning node with detailed endpoint +try await paykitClient.publishPaymentMethod( + methodId: "lightning", + endpoint: "03abc123def4567890123456789012345678901234567890123456789012345678@node.example.com:9735" + // Format: @: + // - node_pubkey: 66 hex character Lightning node public key + // - host: Domain name or IP address + // - port: Lightning P2P port (typically 9735) +) +``` + +**Discovering peer methods:** + +```swift +let pubkey = "8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo" +let methods = try await paykitClient.discoverMethods(pubkey: pubkey) + +for method in methods.entries { + print("Method: \(method.methodId) -> \(method.endpoint)") +} +``` + +### 8.2 Payment Requests (Bitkit core flow) + +Bitkit’s production-facing “paykit://” experience is **payment requests**, not smart checkout. + +Reference implementations: +- iOS: `bitkit-ios/Bitkit/MainNavView.swift` and `bitkit-ios/Bitkit/PaykitIntegration/Services/PaymentRequestService.swift` +- Android: `bitkit-android/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt` + +#### 8.2.1 Publishing a payment request (sender flow) + +Where it is implemented (iOS): `DirectoryService.publishPaymentRequest(_:)` stores at: +- `/pub/paykit.app/v0/requests/` on the sender’s Pubky storage. + +End-to-end steps: + +1. Ensure Paykit is initialized and executors are registered: + - iOS: `PaykitIntegrationHelper.setup()` / `PaykitManager.initialize()` + `registerExecutors()` + - Android: `PaykitIntegrationHelper.setup(lightningRepo)` / `PaykitManager.initialize()` + `registerExecutors(lightningRepo)` +2. Ensure you have a Pubky session (Ring): + - Preferred: `PubkyRingBridge.requestPaykitSetup()` (session + noise keys) +3. Import/restore the session into the Pubky SDK layer. +4. Configure `DirectoryService` with the session. +5. Publish the request JSON to `/pub/paykit.app/v0/requests/`. +6. Generate a receiver deep link: + - `bitkit://payment-request?requestId=&from=` + +#### 8.2.2 Receiving + processing a payment request deep link (receiver flow) + +Supported formats: +- `paykit://payment-request?requestId=&from=` +- `bitkit://payment-request?requestId=&from=` + +Reference flow (iOS, simplified but accurate): + +```swift +func handlePaymentRequestDeepLink(url: URL) async { + guard + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let queryItems = components.queryItems, + let requestId = queryItems.first(where: { $0.name == "requestId" })?.value, + let fromPubkey = queryItems.first(where: { $0.name == "from" })?.value + else { + // Show error to user + return + } + + // Paykit must be initialized (and will fail if Ring isn't connected yet) + if !PaykitManager.shared.isInitialized { + do { + try PaykitManager.shared.initialize() + try PaykitManager.shared.registerExecutors() + } catch { + // “Please connect to Pubky Ring first” + return + } + } + + guard let paykitClient = PaykitManager.shared.client else { return } + + // Autopay evaluation is app policy + let autoPayViewModel = await AutoPayViewModel() + + let paymentRequestService = PaymentRequestService( + paykitClient: paykitClient, + autopayEvaluator: autoPayViewModel, + paymentRequestStorage: PaymentRequestStorage(), + directoryService: DirectoryService.shared + ) + + paymentRequestService.handleIncomingRequest(requestId: requestId, fromPubkey: fromPubkey) { result in + Task { @MainActor in + // Handle: + // - autoPaid(paymentResult) + // - needsApproval(request) + // - denied(reason) + // - error(error) + } + } +} +``` + +Production gaps to call out explicitly: +- iOS currently has a TODO to show an approval UI for `.needsApproval`. +- Android navigates to the Payment Requests screen for manual review. + +### 8.3 Noise Protocol Payments + +Bitkit implements Noise payments via `NoisePaymentService` and `pubky-noise` bindings: +- iOS: `bitkit-ios/Bitkit/PaykitIntegration/Services/NoisePaymentService.swift` +- Android: `bitkit-android/app/src/main/java/to/bitkit/paykit/services/NoisePaymentService.kt` + +**Key integration details for production:** + +1. **Native library dependency:** + - iOS: `PubkyNoise.xcframework` (pre-built, includes arm64 + simulator) + - Android: `libpubky_noise.so` in `jniLibs/` (arm64-v8a, x86_64) + +2. **Noise keypair origin:** + - Keypairs are derived in Ring via `pubky-noise` KDF (see Section 7.2) + - Bitkit receives epoch 0 + epoch 1 keys via `paykit-setup` callback + - Keys are persisted in `NoiseKeyCache` (Keychain/EncryptedSharedPreferences) + +3. **FfiNoiseManager initialization:** + +```swift +// iOS - NoisePaymentService.swift +private func getNoiseManager(isServer: Bool) throws -> FfiNoiseManager { + guard let seedData = PaykitKeyManager.shared.getSecretKeyBytes() else { + throw NoisePaymentError.noIdentity + } + + let deviceId = PaykitKeyManager.shared.getDeviceId() + let deviceIdData = deviceId.data(using: .utf8) ?? Data() + + let config = FfiMobileConfig( + autoReconnect: false, // Manual connection management + maxReconnectAttempts: 0, + reconnectDelayMs: 0, + batterySaver: false, + chunkSize: 32768 // 32KB chunks for mobile networks + ) + + if isServer { + return try FfiNoiseManager.newServer( + config: config, + serverSeed: seedData, + serverKid: "bitkit-ios-server", + deviceId: deviceIdData + ) + } else { + return try FfiNoiseManager.newClient( + config: config, + clientSeed: seedData, + clientKid: "bitkit-ios", + deviceId: deviceIdData + ) + } +} +``` + +4. **Noise IK handshake flow (client-side):** + +```swift +// iOS - Complete handshake sequence +func sendRequestOverNoise(...) async throws -> NoisePaymentResponse { + let manager = try getNoiseManager(isServer: false) + + // Step 1: Parse server's static public key from Noise endpoint + guard let serverPk = recipientNoisePubkey.hexaData as Data? else { + throw NoisePaymentError.invalidEndpoint("Invalid recipient noise pubkey") + } + + // Step 2: Generate first handshake message (IK pattern - we know server's key) + let initResult = try manager.initiateConnection(serverPk: serverPk, hint: nil) + // initResult: { sessionId: String, firstMessage: Data } + + // Step 3: Send first message over TCP + try await sendRawData(initResult.firstMessage, connection: connection) + + // Step 4: Receive server's response + let serverResponse = try await receiveRawData(connection: connection) + + // Step 5: Complete handshake - session is now encrypted + let sessionId = try manager.completeConnection( + sessionId: initResult.sessionId, + serverResponse: serverResponse + ) + + Logger.info("Noise handshake completed, session: \(sessionId)", context: "NoisePaymentService") + + // Step 6: Encrypt payment request + let jsonData = try JSONEncoder().encode(paymentMessage) + let ciphertext = try manager.encrypt(sessionId: sessionId, plaintext: jsonData) + + // Step 7: Send encrypted message + try await sendRawData(ciphertext, connection: connection) + + // Step 8: Receive and decrypt response + let responseCiphertext = try await receiveRawData(connection: connection) + let responsePlaintext = try manager.decrypt(sessionId: sessionId, ciphertext: responseCiphertext) + + return try JSONDecoder().decode(NoisePaymentResponse.self, from: responsePlaintext) +} +``` + +5. **Endpoint discovery before connection:** + +```swift +// Discover recipient's Noise endpoint from their Pubky directory +guard let endpoint = try? await DirectoryService.shared.discoverNoiseEndpoint( + for: request.payeePubkey +) else { + // Fallback to async payment request (Section 8.2) + throw NoisePaymentError.endpointNotFound +} + +// endpoint: NoiseEndpointInfo { +// host: "192.168.1.100:9737", // Host:port for TCP connection +// serverNoisePubkey: "abcd1234..." // 64 hex chars X25519 public key +// } +``` + +6. **Server mode (receiving Noise payments):** + +```kotlin +// Android - NoisePaymentService.kt +private var serverSocket: java.net.ServerSocket? = null +private var isServerRunning = false + +suspend fun startServer(port: Int, onRequest: (NoisePaymentRequest) -> Unit) { + val manager = getNoiseManager(isServer = true) + + serverSocket = ServerSocket(port) + isServerRunning = true + + while (isServerRunning) { + val clientSocket = serverSocket?.accept() ?: break + + // Handle in coroutine + scope.launch { + handleClientConnection(clientSocket, manager, onRequest) + } + } +} + +private suspend fun handleClientConnection( + socket: Socket, + manager: FfiNoiseManager, + onRequest: (NoisePaymentRequest) -> Unit +) { + // Server-side handshake (respond to client's IK initiation) + val clientFirstMessage = receiveRawData(socket) + + val respondResult = try { + manager.respondToConnection(clientFirstMessage, null) + } catch (e: Exception) { + socket.close() + return + } + + sendRawData(socket, respondResult.responseMessage) + + // Session established - receive encrypted payment request + val ciphertext = receiveRawData(socket) + val plaintext = manager.decrypt(respondResult.sessionId, ciphertext) + val request = Json.decodeFromString(plaintext.decodeToString()) + + onRequest(request) +} +``` + +**Reference high-level API (simplified for app developers):** + +```kotlin +// Android +val request = NoisePaymentRequest( + payerPubkey = payerPubkey, + payeePubkey = payeePubkey, + methodId = "lightning", + amount = "50000", + currency = "SAT", + description = "Payment for services", +) + +val response = noisePaymentService.sendPaymentRequest(request) +if (!response.success) { + // Handle error_code / error_message from response +} +``` + +### 8.4 Subscriptions + +```swift +// Create subscription +let subscription = try await paykitClient.createSubscription( + providerPubkey: providerPubkey, + amount: 10000, + currency: "SAT", + frequency: .monthly, + description: "Premium membership" +) + +// Enable auto-pay for this subscription +try await paykitClient.enableAutoPay( + subscriptionId: subscription.id, + maxAmountSats: 15000, + requireConfirmation: false +) +``` + +### 8.5 Spending Limits + +```swift +// Set global daily limit +try await paykitClient.setGlobalDailyLimit(amountSats: 100000) + +// Set per-peer limit +try await paykitClient.setPeerLimit( + peerPubkey: merchantPubkey, + amountSats: 50000, + period: .weekly +) + +// Check remaining limit before payment +let remaining = try await paykitClient.getRemainingLimit(peerPubkey: merchantPubkey) +if remaining >= paymentAmount { + // Proceed with payment +} +``` + +--- + +## 9. Known Quirks & Footguns + +### 9.1 Build Issues + +#### ⚠️ Homebrew Rust vs Rustup + +**Problem:** Homebrew Rust doesn't support cross-compilation targets. + +**Symptom:** +``` +Error: wasm32-unknown-unknown target not found in sysroot +``` + +**Solution:** +```bash +brew uninstall rust +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +rustup target add wasm32-unknown-unknown +``` + +#### ⚠️ WASM async_trait Send Bounds + +**Problem:** `async_trait` requires `Send` by default, but WASM futures aren't `Send`. + +**Symptom:** +``` +future cannot be sent between threads safely +``` + +**Solution:** Use conditional compilation: +```rust +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +pub trait PrivateEndpointStore: Send + Sync +``` + +#### ⚠️ UniFFI Version Mismatch + +**Problem:** Generated bindings must match the UniFFI version used to build. + +**Symptom:** +``` +uniffi checksum mismatch +``` + +**Solution:** Always regenerate bindings after updating UniFFI: +```bash +cargo install uniffi-bindgen-cli@0.25 # Match Cargo.toml version +./paykit-mobile/generate-bindings.sh +``` + +#### ⚠️ Android NDK Path + +**Problem:** Build scripts can't find NDK. + +**Solution:** Set environment variable: +```bash +export ANDROID_NDK_HOME=$HOME/Library/Android/sdk/ndk/25.2.9519653 +``` + +Also create `local.properties` in Android project: +```properties +sdk.dir=/Users/YOUR_USER/Library/Android/sdk +``` + +#### ⚠️ iOS XCFramework workflow (PaykitMobile.xcframework) + +Bitkit iOS consumes `PaykitMobile.xcframework`, not a raw `.a` file. The correct rebuild command is: + +```bash +cd paykit-rs/paykit-mobile +./build-ios.sh --framework +``` + +Then copy: +- `paykit-mobile/ios-demo/PaykitDemo/PaykitDemo/Frameworks/PaykitMobile.xcframework` +to: +- `bitkit-ios/Bitkit/PaykitIntegration/Frameworks/` + +If you forget `--framework`, the build will succeed but Bitkit won’t have a consumable XCFramework (common footgun). + +### 9.2 Runtime Issues + +#### ⚠️ Thread Safety with Noise Channels + +**Problem:** Noise channels are not `Send` - cannot be used across threads. + +**Solution:** Keep channel operations on the same thread/task: +```swift +// WRONG +Task.detached { + await channel.send(message) // May be different thread +} + +// RIGHT +await withCheckedContinuation { continuation in + channelQueue.async { + channel.send(message) + continuation.resume() + } +} +``` + +#### ⚠️ Lock Poisoning Policy + +**Problem:** Mutex poisoning after panic can cause cascading failures. + +**Policy:** We use `lock().expect()` and accept panics on poison. + +**Rationale:** If a thread panics while holding a lock, the data may be corrupt. Better to crash than corrupt payments. + +See: `paykit-rs/docs/CONCURRENCY.md` + +#### ⚠️ Never Call block_on() in Async Context + +**Problem:** Calling `block_on()` from an async task deadlocks. + +**Symptom:** App hangs indefinitely. + +**Solution:** Use the FFI's async bridge: +```kotlin +// WRONG +runBlocking { + paykitClient.discover(pubkey) +} + +// RIGHT +viewModelScope.launch { + paykitClient.discoverAsync(pubkey) +} +``` + +#### ⚠️ Executor bridging (Bitkit executors are synchronous) + +Paykit’s executor interfaces are synchronous at the FFI boundary. Bitkit bridges to async payment systems by blocking on background threads: + +- iOS: `bitkit-ios/Bitkit/PaykitIntegration/Executors/BitkitLightningExecutor.swift` + - Uses `DispatchSemaphore` to wait for `LightningService.send(...)`. + - Polls `lightningService.payments` to extract the preimage. + - Enforces a timeout (default 60s). +- Android: `bitkit-android/app/src/main/java/to/bitkit/paykit/executors/BitkitLightningExecutor.kt` + - Uses `runBlocking(Dispatchers.IO)` + `withTimeout`. + - Polls `LightningRepo.getPayments()` to extract preimage/proof. + +Production blueprint requirements: +- Ensure the executor never runs on the main thread (deadlock risk). +- Treat timeouts as first-class failures (surface actionable error to user). +- Prefer structured concurrency over global blocking primitives where possible. + +#### ⚠️ Homeserver base URL naming confusion + +In Bitkit, configuration strings are sometimes labeled “homeserver pubkey”, but the HTTP storage adapters build URLs by concatenating: +- `"$homeserverBaseURL/pubky$ownerPubkey$path"` (unauthenticated reads) +- `"$homeserverBaseURL$path"` (authenticated writes) + +Production blueprint requirements: +- If using the HTTP adapters, ensure `homeserverBaseURL` is a real URL (e.g., `https://homeserver.pubky.app`). +- If relying on `pubky://` URIs + DHT/Pkarr resolution, leave `homeserverBaseURL` unset and use `pubky://` reads (see `DirectoryService.fetchPaymentRequest` on iOS/Android). + +#### ⚠️ Android GlobalScope usage in PubkyRingBridge + +`PubkyRingBridge.kt` persists sessions using `GlobalScope.launch(Dispatchers.IO)` which is not production-safe. Blueprint requirement: +- Replace `GlobalScope` persistence with an injected `CoroutineScope` tied to app lifecycle or a repository/service scope. + +### 9.3 Platform-Specific Issues + +#### iOS Keychain Entitlements + +**Problem:** Keychain access fails without proper entitlements. + +**Solution:** Add to `Bitkit.entitlements`: +```xml +keychain-access-groups + + $(AppIdentifierPrefix)to.bitkit.paykit + +``` + +#### Android ProGuard Rules + +**Problem:** ProGuard strips JNA classes. + +**Solution:** Add to `proguard-rules.pro`: +```proguard +-keep class com.sun.jna.** { *; } +-keep class uniffi.paykit_mobile.** { *; } +``` + +#### Background Processing Limits + +**Problem:** iOS kills background tasks after ~30 seconds. + +**Solution:** Use `BGProcessingTask` for subscription checks: +```swift +BGTaskScheduler.shared.register( + forTaskWithIdentifier: "to.bitkit.paykit.subscriptionCheck", + using: nil +) { task in + self.handleSubscriptionCheck(task as! BGProcessingTask) +} +``` + +--- + +## 10. Stubs & Mocks Inventory + +### Components Still Using Mocks + +| Component | Location | What's Mocked | Production Requirement | +|-----------|----------|---------------|------------------------| +| Directory Transport | `paykit-demo-web/src/directory.rs` | localStorage publishing | Real Pubky homeserver | +| Payment Execution | `paykit-lib/src/methods/onchain.rs` | Mock transaction result | Real Esplora/LND executor | +| Noise Transport | Demo apps | TCP/WebSocket | Real Noise over WS | +| Key Storage | `paykit-demo-cli` | Plaintext JSON | OS Keychain/Keystore | + +### Mock APIs Available + +```rust +// These are for testing ONLY - do not use in production + +// Mock transport (no network calls) +let transport = AuthenticatedTransportFfi::new_mock(); +assert!(transport.is_mock()); // Returns true + +// Production transport +let transport = AuthenticatedTransportFfi::from_callback(callback); +assert!(!transport.is_mock()); // Returns false +``` + +### Production Transport Implementation + +For Bitkit, the “production transport implementation” is the pair of storage adapters + UniFFI callback transports. This matches the code the team should follow. + +```swift +// 1) Configure DirectoryService with session and build the transports +DirectoryService.shared.initialize(client: paykitClient) +DirectoryService.shared.configureWithPubkySession(session) + +// Internally, DirectoryService wires: +// - UnauthenticatedTransportFfi.fromCallback(callback: PubkyUnauthenticatedStorageAdapter(homeserverBaseURL: )) +// - AuthenticatedTransportFfi.fromCallback(callback: PubkyAuthenticatedStorageAdapter(sessionId: session.sessionSecret, homeserverBaseURL: ), ownerPubkey: session.pubkey) + +// 2) The authenticated adapter attaches the session via cookie: +// Cookie: session= +``` + +### Background polling (Bitkit production blueprint) + +Bitkit iOS implements a full polling service: +- `bitkit-ios/Bitkit/PaykitIntegration/Services/PaykitPollingService.swift` + +What the team must do for production: +- Add `to.bitkit.paykit.polling` to `BGTaskSchedulerPermittedIdentifiers` in Info.plist. +- Call `PaykitPollingService.shared.registerBackgroundTask()` at startup. +- Call `PaykitPollingService.shared.startForegroundPolling()` when entering foreground. +- Call `PaykitPollingService.shared.scheduleBackgroundPoll()` when entering background. + +Bitkit Android implements WorkManager polling: +- `bitkit-android/app/src/main/java/to/bitkit/paykit/workers/PaykitPollingWorker.kt` + +What the team must do for production: +- Call `PaykitPollingWorker.schedule(context)` once the wallet is ready and Paykit is enabled. +- Ensure notification channel permissions and runtime permissions are handled for Android 13+. + +### ProGuard / R8 rules (Android production) + +Bitkit Android currently has an essentially empty `app/proguard-rules.pro`. For release builds using UniFFI + JNA you should add rules to avoid stripping: + +```proguard +-keep class com.sun.jna.** { *; } +-keep class uniffi.paykit_mobile.** { *; } +-keep class com.pubky.noise.** { *; } +``` + +### What Needs Real Implementation + +| Feature | Demo Behavior | Production Need | +|---------|---------------|-----------------| +| `OnchainPlugin.execute()` | Returns mock txid | Connect to Esplora/electrum | +| `LightningPlugin.execute()` | Returns mock preimage | Connect to LND/CLN/LDK | +| `NoiseServerHelper` | In-memory | Persistent connection state | +| `FileStorage` | Plaintext JSON | Encrypted database | + +--- + +## 11. Testing Requirements + +### 11.1 Unit Tests + +**Location:** Each crate's `tests/` directory + +**Run all tests:** +```bash +cd paykit-rs +cargo test --all --all-features +``` + +**Key test files:** +- `paykit-lib/tests/methods_test.rs` - Payment method validation +- `paykit-subscriptions/tests/subscription_test.rs` - Subscription lifecycle +- `paykit-interactive/tests/protocol_test.rs` - Noise protocol messages + +### 11.2 Integration Tests + +**Run with network access:** +```bash +cargo test --features integration-tests -- --test-threads=1 +``` + +**Disabled tests (need SDK update):** +- `pubky_sdk_compliance.rs` - Pubky SDK API changed + +### 11.3 Mobile Tests + +**iOS:** +```bash +cd bitkit-ios +xcodebuild test -scheme Bitkit -destination 'platform=iOS Simulator,name=iPhone 15' +``` + +**Android:** +```bash +cd bitkit-android +./gradlew testDevDebugUnitTest +./gradlew connectedDevDebugAndroidTest +``` + +### 11.4 Manual Test Checklist + +Before release, manually verify: + +- [ ] Create identity in Ring +- [ ] Import identity in Bitkit +- [ ] Publish payment methods +- [ ] Scan QR code for Pubky URI +- [ ] Smart checkout flow completes +- [ ] Lightning payment executes +- [ ] Onchain payment executes +- [ ] Create subscription +- [ ] Auto-pay triggers correctly +- [ ] Spending limit enforced +- [ ] Deep links work (all schemes) +- [ ] Background subscription check runs +- [ ] App recovers from network failure +- [ ] Keys persist across app restart + +### 11.5 E2E Test Scenarios + +```bash +# Start test environment +cd paykit-rs +./scripts/start-testnet.sh + +# Run E2E tests +cargo test --features e2e-tests +``` + +--- + +## 12. Production Configuration + +### Environment Variables + +| Variable | Description | Example | +|----------|-------------|---------| +| `PAYKIT_HOMESERVER_URL` | Pubky homeserver URL | `https://homeserver.pubky.org` | +| `PAYKIT_LOG_LEVEL` | Logging verbosity | `info`, `debug`, `trace` | +| `PAYKIT_RATE_LIMIT_BURST` | Rate limit burst size | `10` | +| `PAYKIT_RATE_LIMIT_PERIOD_SECS` | Rate limit window | `60` | + +### iOS Configuration + +```swift +// Config.swift +struct PaykitConfig { + static let homeserverURL = ProcessInfo.processInfo.environment["PAYKIT_HOMESERVER_URL"] + ?? "https://homeserver.pubky.org" + + static let rateLimitConfig = RateLimitConfig( + maxHandshakesPerMinute: 10, + maxHandshakesGlobal: 100 + ) +} +``` + +### Android Configuration + +```kotlin +// PaykitConfig.kt +object PaykitConfig { + val homeserverUrl: String = BuildConfig.PAYKIT_HOMESERVER_URL + + val rateLimitConfig = RateLimitConfig( + maxHandshakesPerMinute = 10, + maxHandshakesGlobal = 100 + ) +} +``` + +### Server Requirements + +| Service | Purpose | Minimum Spec | +|---------|---------|--------------| +| Pubky Homeserver | Directory storage | 2 CPU, 4GB RAM | +| Lightning Node | Payment execution | 4 CPU, 8GB RAM | +| Bitcoin Node | Onchain payments | 8 CPU, 16GB RAM | + +### Feature flags (Paykit rollout controls) + +Reference implementations: +- iOS: `bitkit-ios/Bitkit/PaykitIntegration/PaykitFeatureFlags.swift` +- Android: `bitkit-android/app/src/main/java/to/bitkit/paykit/PaykitFeatureFlags.kt` + +Blueprint requirements: +- Initialize defaults on first launch (`PaykitFeatureFlags.setDefaults()` / `PaykitFeatureFlags.init(context)`). +- Gate Paykit UI entry points behind `PaykitFeatureFlags.isEnabled`. +- Support remote-config overrides (keys are already defined). +- Ensure `emergencyRollback()` resets Paykit state and disables Paykit immediately. + +### Observability: PaykitLogger and config + +Reference implementations: +- iOS: `bitkit-ios/Bitkit/PaykitIntegration/PaykitLogger.swift` and `PaykitConfigManager` +- Android: `bitkit-android/app/src/main/java/to/bitkit/paykit/PaykitLogger.kt` and `PaykitConfigManager` + +Blueprint requirements: +- Route Paykit logs through a single structured logger (avoid ad-hoc `print` / `Log.d`). +- Ensure payment details logging is disabled in production (`logPaymentDetails = false`) for privacy. +- Wire `errorReporter` into your monitoring pipeline (Sentry, Crashlytics, etc.). + +--- + +## 13. Security Checklist + +### Cryptographic Requirements + +- [x] Ed25519 for identity and signatures +- [x] X25519 for Noise key exchange +- [x] HKDF for key derivation +- [x] AES-256-GCM for storage encryption +- [x] Argon2 for password-based key derivation + +### Key Storage + +- [ ] iOS: Keys in Keychain with `kSecAttrAccessibleAfterFirstUnlock` +- [ ] Android: Keys in EncryptedSharedPreferences with hardware-backed keystore +- [ ] Never log keys or secrets +- [ ] Zeroize sensitive data after use + +### Transport Security + +- [ ] TLS 1.3 for all HTTP connections +- [ ] Certificate pinning for homeserver +- [ ] Noise_IK for payment channels +- [ ] No sensitive data in URLs + +### Input Validation + +- [ ] Validate all pubkeys are valid z-base-32 +- [ ] Validate all amounts are positive +- [ ] Sanitize paths (no `..` traversal) +- [ ] Validate invoice expiration before payment + +### Replay Protection + +- [ ] Nonces stored in persistent database +- [ ] Nonce checked BEFORE signature verification +- [ ] Expired nonces cleaned up automatically +- [ ] Timestamps validated (not future-dated) + +--- + +## 14. Troubleshooting + +### Build Errors + +**"Library not found for -lpaykit_mobile"** +- Check Library Search Paths in Xcode +- Verify `.a` file is in the correct location +- Run `cargo build --release -p paykit-mobile` + +**"uniffi checksum mismatch"** +- Regenerate bindings with matching UniFFI version +- Delete derived data and rebuild + +**"wasm32-unknown-unknown target not found"** +- Switch from Homebrew Rust to Rustup +- Run `rustup target add wasm32-unknown-unknown` + +### Runtime Errors + +**"Failed to load native library"** +- Check SO files are in correct jniLibs folders +- Verify ABI filters in build.gradle match +- Check ProGuard isn't stripping JNA + +**"Keychain access denied"** +- Add keychain-access-groups entitlement +- Check app identifier prefix + +**"Session expired"** +- Request new session from Ring +- Check system clock is accurate + +### Network Errors + +**"Homeserver unreachable"** +- Check network connectivity +- Verify homeserver URL is correct +- Check for certificate issues + +**"Noise handshake failed"** +- Verify peer pubkey is correct +- Check rate limiting isn't triggered +- Ensure both sides support Noise_IK + +--- + +## 15. Future Work + +### Planned Features + +| Feature | Priority | Status | +|---------|----------|--------| +| Hardware wallet signing | High | Not started | +| Multi-signature support | Medium | Design phase | +| LNURL integration | Medium | Planned | +| Bolt12 support | Medium | Planned | +| Desktop Electron app | Low | Not started | + +### Known Limitations + +1. **Single homeserver**: Currently only supports one homeserver per user +2. **No offline payments**: Requires network for all operations +3. **Manual key backup**: No automatic cloud backup +4. **Limited payment proofs**: Basic receipt, not cryptographic proof + +### Upgrade Paths + +**Pubky SDK Migration:** +When Pubky SDK updates, check: +- `PubkyClient` API changes +- Session management changes +- Homeserver protocol version + +**UniFFI Updates:** +When updating UniFFI: +1. Update version in all `Cargo.toml` +2. Regenerate all bindings +3. Test on all platforms + +--- + +## 16. Production Implementation Checklist + +This comprehensive checklist covers everything the production team must verify before shipping Paykit integration. + +### 16.1 Build & Dependencies + +- [ ] **Rust toolchain** is via Rustup (NOT Homebrew) +- [ ] **Rust targets** added for all platforms: + - `aarch64-apple-ios`, `aarch64-apple-ios-sim`, `x86_64-apple-ios` + - `aarch64-linux-android`, `armv7-linux-androideabi`, `i686-linux-android`, `x86_64-linux-android` +- [ ] **UniFFI version** matches across all crates (check `Cargo.toml` versions) +- [ ] **paykit-mobile** builds successfully: `cargo build --release -p paykit-mobile` +- [ ] **pubky-noise** builds successfully for all targets +- [ ] **XCFrameworks** generated and copied to iOS projects (PaykitMobile + PubkyNoise) +- [ ] **.so files** generated and copied to Android jniLibs (both paykit_mobile + pubky_noise) +- [ ] **Swift/Kotlin bindings** regenerated after any Rust changes + +### 16.2 iOS Integration + +- [ ] `PaykitMobile.xcframework` added to Xcode project +- [ ] `PubkyNoise.xcframework` added to Xcode project +- [ ] `PaykitMobile.swift` FFI bindings compile without errors +- [ ] `PubkyNoise.swift` FFI bindings compile without errors +- [ ] URL schemes registered in `Info.plist`: `bitkit`, `paykit` +- [ ] Keychain entitlements configured for Paykit storage +- [ ] Background task registered: `to.bitkit.paykit.session-refresh` +- [ ] Background task registered: `to.bitkit.paykit.polling` +- [ ] `SessionRefreshService.registerBackgroundTask()` called at startup +- [ ] `PaykitPollingService.registerBackgroundTask()` called at startup +- [ ] Deep link handling routes `paykit://` and `bitkit://paykit-*` correctly +- [ ] Push token lifecycle wired: on APNs device token update, call `PushRelayService.register(token:)` (after identity/session exists) + +### 16.3 Android Integration + +- [ ] `libpaykit_mobile.so` present in jniLibs for all ABIs +- [ ] `libpubky_noise.so` present in jniLibs for all ABIs +- [ ] `local.properties` has correct `sdk.dir` path +- [ ] ProGuard rules added for JNA and UniFFI classes +- [ ] Intent filters registered for `bitkit`, `paykit` schemes +- [ ] `SessionRefreshWorker.schedule(context)` called at startup +- [ ] `PaykitPollingWorker.schedule(context)` called when Paykit enabled +- [ ] Deep link handling in `AppViewModel.handleDeepLink()` works +- [ ] Push token lifecycle wired: on FCM token update, call `PushRelayService.register(deviceToken)` (after identity/session exists) + +### 16.4 Pubky Ring Integration + +- [ ] `PubkyNoiseModule` native module builds and links (iOS + Android) +- [ ] `pubkyring://paykit-connect` deep link handler works +- [ ] Session + noise keys returned correctly via callback +- [ ] Cross-device QR code generation works +- [ ] Cross-device relay polling works (5-minute timeout) +- [ ] Ring correctly derives X25519 keys using `deriveDeviceKey` + +### 16.5 Session Management + +- [ ] `PubkyRingBridge.requestPaykitSetup()` returns session + noise keys +- [ ] Session imported into `PubkySDKService.importSession()` +- [ ] Session persisted to Keychain/EncryptedSharedPreferences +- [ ] Noise keys (epoch 0 + 1) cached in `NoiseKeyCache` +- [ ] Session refresh runs in background (hourly) +- [ ] Expired sessions trigger re-authentication flow + +### 16.6 Feature Implementation + +- [ ] Payment method publishing works (`paykitClient.publishPaymentMethod`) +- [ ] Payment method discovery works (`paykitClient.discoverMethods`) +- [ ] Payment request publishing works (DirectoryService) +- [ ] Payment request receiving works (deep link + polling) +- [ ] Noise IK handshake completes successfully (client + server mode) +- [ ] Encrypted Noise messages exchange works +- [ ] Lightning executor connected to real LDK/LND/CLN +- [ ] Onchain executor connected to real Esplora/Electrum +- [ ] Subscriptions create and persist correctly +- [ ] Auto-pay evaluates rules and executes payments +- [ ] Spending limits enforce correctly + +### 16.7 Error Handling + +- [ ] Network failures show user-friendly messages +- [ ] Session expiration prompts re-authentication +- [ ] Ring not installed shows install prompt +- [ ] Noise connection failures fallback to async payments +- [ ] Payment failures show specific error codes + +### 16.8 Security + +- [ ] No hardcoded secrets in source code +- [ ] Session secrets stored in Keychain/Keystore only +- [ ] Noise private keys stored in Keychain/Keystore only +- [ ] ProGuard rules prevent reflection stripping +- [ ] Log level set to `info` in production (not `debug`) +- [ ] Payment details logging disabled (`logPaymentDetails = false`) +- [ ] Rate limiting enabled on Noise server endpoints + +### 16.9 Testing + +- [ ] Unit tests pass: `cargo test --all --all-features` +- [ ] iOS tests pass: `xcodebuild test` +- [ ] Android tests pass: `./gradlew testDevDebugUnitTest` +- [ ] Manual test checklist completed (Section 11.4) +- [ ] Two-device Noise payment tested +- [ ] Cross-device Ring authentication tested +- [ ] Background polling verified with Xcode/Android Studio debugger + +### 16.10 Production Config + +- [ ] Homeserver URL configured (not localhost) +- [ ] Relay URL configured for cross-device auth +- [ ] Feature flags default to enabled +- [ ] Emergency rollback function tested +- [ ] Error reporting wired to monitoring (Sentry/Crashlytics) +- [ ] Analytics events defined for key flows + +--- + +## 17. Architectural Hardening + +The following architectural improvements were implemented to enhance security, reliability, and maintainability. + +### 17.1 Ring-Only Identity Model (Phase 1) + +**Problem**: Bitkit storing Ed25519 secrets created unclear key ownership and security boundaries. + +**Solution**: Ed25519 master keys now owned exclusively by Pubky Ring. + +**Benefits**: +- Clear security boundary: Ring = identity, Bitkit = payments +- Reduced attack surface: Bitkit compromise doesn't expose master key +- Better separation of concerns + +**Key Changes**: +- Removed Ed25519 secret generation and storage from `KeyManager` (iOS + Android) +- Added cache miss recovery via `getOrRefreshKeypair()` +- Added key rotation support via `checkKeyRotation()` and `setCurrentEpoch()` + +**Key Rotation Status**: Rotation infrastructure is implemented but **manual only**. +- Call `checkKeyRotation(forceRotation: true)` to rotate from epoch 0 to epoch 1 +- Automatic time-based rotation is planned but not yet implemented +- Production deployments should schedule periodic rotation checks or trigger on security events + +**Implementation Details**: See [PHASE_1-4_IMPROVEMENTS.md](PHASE_1-4_IMPROVEMENTS.md#phase-1-ring-only-identity-model) + +### 17.2 Secure Handoff Protocol (Phase 2) + +**Problem**: Session secrets passed in callback URLs are vulnerable to logging/leaks. + +**Solution**: Store handoff payload on homeserver at unguessable path, return only `request_id` in URL. + +**Benefits**: +- No secrets in URLs (immune to logging attacks) +- 256-bit random path (unguessable, 2^256 combinations) +- 5-minute TTL (time-limited exposure) +- Immediate deletion after fetch (defense in depth) + +**Protocol**: +1. Ring stores handoff payload as JSON at `/pub/paykit.app/v0/handoff/{request_id}` +2. Ring returns: `bitkit://paykit-setup?mode=secure_handoff&pubky=...&request_id=...` +3. Bitkit fetches payload from homeserver using `request_id` +4. Bitkit deletes payload immediately after fetch (iOS + Android) + +**Security Properties** (Note: payload is NOT encrypted at rest): +- **Path unguessability**: 256-bit random request_id makes brute-force infeasible +- **Time-limited**: 5-minute `expires_at` timestamp in payload +- **Transport encryption**: TLS protects data in transit +- **Immediate cleanup**: Bitkit deletes after fetch; homeserver should honor TTL +- **Access control**: Authenticated write, public read (security via obscurity of path) + +**Protocol Flow Diagram**: See [PHASE_1-4_IMPROVEMENTS.md](PHASE_1-4_IMPROVEMENTS.md#protocol-flow) + +### 17.3 Private Push Relay (Phase 3) + +**Problem**: Publishing device tokens publicly enables DoS via notification spam and privacy leaks. + +**Solution**: Server-side token storage with authenticated wake requests and rate limiting. + +**Benefits**: +- Tokens never exposed publicly (no DoS risk) +- Rate limiting at relay level (10/min per sender, 100/hour per recipient) +- Ed25519 signature authentication required +- Privacy: relay sees only routing metadata, not message content + +**API Specification**: See [PUSH_RELAY_DESIGN.md](PUSH_RELAY_DESIGN.md) + +**Key Components**: +- `PushRelayService` (iOS + Android): Client for registration and wake requests +- Ed25519 signing via Ring: `requestSignature(message:)` method added +- Deprecated public publishing methods in `DirectoryService` + +**Production wiring required (not automatic in the reference apps)**: +- Call `PushRelayService.register(...)` after the app has both: + - a valid push token (APNs on iOS, FCM on Android), and + - an active Pubky identity/session (from Ring setup). +- Re-register when the push token rotates or the Pubky session is replaced. +- Do not use the deprecated directory-based push publishing/discovery methods in production. + +**Ed25519 Signing Flow**: +```swift +// iOS +let signature = try await PubkyRingBridge.shared.requestSignature(message: message) + +// Android +val signature = pubkyRingBridge.requestSignature(context, message) +``` + +### 17.4 Type-Safe Identifiers (Phase 4) + +**Problem**: Raw strings used for both pubkeys and URLs, causing confusion and potential bugs. + +**Solution**: Distinct types with validation, normalization, and centralized resolution. + +**Types Introduced**: +- `HomeserverPubkey`: z32 Ed25519 pubkey identifying a homeserver +- `HomeserverURL`: Resolved HTTPS URL for API requests +- `OwnerPubkey`: z32 Ed25519 pubkey identifying a user +- `SessionSecret`: Secure wrapper for session credentials (auto-redacts when logged) + +**HomeserverResolver**: +- Centralized pubkey→URL mapping with caching (1-hour TTL) +- Known homeserver mappings preloaded +- Supports custom mappings via `addMapping()` +- Override support for testing/development + +**Adoption**: +- `DirectoryService` now uses `HomeserverURL` and `OwnerPubkey` (iOS + Android) +- `PubkyStorageAdapter` constructors accept `HomeserverURL` type (iOS + Android) +- Type safety prevents passing pubkeys where URLs expected (and vice versa) + +**Usage**: +```swift +// iOS +let pubkey = HomeserverPubkey("8um71us3fyw6h...") +let url = HomeserverResolver.shared.resolve(pubkey: pubkey) +directoryService.configurePubkyTransport(homeserverURL: url) + +// Android +val pubkey = HomeserverPubkey("8um71us3fyw6h...") +val url = HomeserverResolver.resolve(pubkey) +directoryService.configurePubkyTransport(homeserverURL = url) +``` + +### 17.5 Security Model Summary + +For comprehensive security documentation, including threat model, attack surface analysis, and cryptographic protocols, see [SECURITY_ARCHITECTURE.md](SECURITY_ARCHITECTURE.md). + +**Key Security Properties**: +- **Identity confidentiality**: Ed25519 secrets never leave Ring +- **Forward secrecy**: X25519 ephemeral keys for Noise channels +- **Authenticity**: Ed25519 signatures on all sensitive operations +- **Availability**: Rate limiting prevents DoS attacks +- **Defense in depth**: Multiple layers (TTL, deletion, TLS, authentication) + +--- + +## Appendices + +### A. File Manifest + +**paykit-rs files created/modified:** +``` +paykit-mobile/ +├── src/lib.rs # FFI exports +├── src/interactive_ffi.rs # Noise protocol FFI +├── src/executor_ffi.rs # Payment executor FFI +├── swift/ # iOS storage adapters +└── kotlin/ # Android storage adapters +``` + +**bitkit-ios files created:** +``` +Bitkit/PaykitIntegration/ +├── FFI/paykit_mobile.swift # Generated bindings +├── Services/PaykitManager.swift +├── Services/DirectoryService.swift +├── Services/NoisePaymentService.swift +├── Storage/PaykitKeychainStorage.swift +└── Views/*.swift # UI components +``` + +**bitkit-android files created:** +``` +app/src/main/java/ +├── uniffi/paykit_mobile/ # Generated bindings +└── to/bitkit/paykit/ + ├── services/PaykitManager.kt + ├── services/DirectoryService.kt + ├── storage/PaykitSecureStorage.kt + └── ui/screens/*.kt # UI components +``` + +### B. Dependency Versions + +| Dependency | Version | Notes | +|------------|---------|-------| +| Rust | 1.75+ | Via Rustup | +| UniFFI | 0.25.3 | Must match across all crates | +| Pubky SDK | 0.6.0-rc.6 | API breaking changes pending | +| pubky-noise | 1.0.0+ | `deriveDeviceKey` throws in 1.1+ | +| pubky-core | 0.6.0-rc.6 | Used via BitkitCore for homeserver ops | +| LDK Node | 0.3.0 | Lightning payments | + +### C. Glossary + +| Term | Definition | +|------|------------| +| **Pkarr** | Public Key Addressable Resource Records - DNS-like system for pubkeys | +| **Pubky** | Public Key + Y (identity) - decentralized identity system | +| **Noise Protocol** | Cryptographic handshake framework for secure channels | +| **z-base-32** | Human-friendly encoding for Ed25519 public keys | +| **Homeserver** | Pubky server that stores user data | +| **FFI** | Foreign Function Interface - bridge between Rust and mobile | +| **UniFFI** | Mozilla's tool for generating FFI bindings | + +--- + +*This guide was generated from the reference implementation in the BitcoinErrorLog repositories. For questions, open an issue in the relevant repository.* +