Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 30 additions & 21 deletions Bitkit/MainNavView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down
161 changes: 93 additions & 68 deletions Bitkit/PaykitIntegration/KeyManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,32 @@
// 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()

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 {
Expand All @@ -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
Expand All @@ -87,73 +68,117 @@ 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

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."
}
}
}
Expand Down
11 changes: 7 additions & 4 deletions Bitkit/PaykitIntegration/PaykitManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
Loading