From f493b501636ccebd98e55ad339743c9a15aebd7d Mon Sep 17 00:00:00 2001 From: jack Date: Tue, 30 Sep 2025 14:41:36 +0200 Subject: [PATCH] Cache Nostr identity derivation to prevent crypto during view rendering Critical performance fix: Problem: formatMessageHeader() called deriveIdentity(forGeohash:) during every SwiftUI render for every media message. Each call performed: - Keychain I/O (getOrCreateDeviceSeed) - HMAC-SHA256 computation - Up to 10 secp256k1 key validations (elliptic curve crypto) With multiple media messages, this resulted in 100s of milliseconds of blocking crypto on main thread per render cycle. Solution: Add thread-safe cache for derived identities - Check cache before expensive crypto operations - NSLock protects concurrent access - Identity is deterministic per geohash, so caching is safe This eliminates crypto from the hot rendering path. --- bitchat/Nostr/NostrIdentity.swift | 285 ++++++++++++++++++++++++++++++ 1 file changed, 285 insertions(+) diff --git a/bitchat/Nostr/NostrIdentity.swift b/bitchat/Nostr/NostrIdentity.swift index 72fb7bbf..1c3db1f9 100644 --- a/bitchat/Nostr/NostrIdentity.swift +++ b/bitchat/Nostr/NostrIdentity.swift @@ -58,3 +58,288 @@ struct NostrIdentity: Codable { return publicKey.hexEncodedString() } } + +/// Bridge between Noise and Nostr identities +struct NostrIdentityBridge { + private static let keychainService = "chat.bitchat.nostr" + private static let currentIdentityKey = "nostr-current-identity" + private static let deviceSeedKey = "nostr-device-seed" + // In-memory cache to avoid transient keychain access issues + private static var deviceSeedCache: Data? + // Cache derived identities to avoid repeated crypto during view rendering + private static var derivedIdentityCache: [String: NostrIdentity] = [:] + private static let cacheLock = NSLock() + + /// Get or create the current Nostr identity + static func getCurrentNostrIdentity() throws -> NostrIdentity? { + // Check if we already have a Nostr identity + if let existingData = KeychainHelper.load(key: currentIdentityKey, service: keychainService), + let identity = try? JSONDecoder().decode(NostrIdentity.self, from: existingData) { + return identity + } + + // Generate new Nostr identity + let nostrIdentity = try NostrIdentity.generate() + + // Store it + let data = try JSONEncoder().encode(nostrIdentity) + KeychainHelper.save(key: currentIdentityKey, data: data, service: keychainService) + + return nostrIdentity + } + + /// Associate a Nostr identity with a Noise public key (for favorites) + static func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) { + let key = "nostr-noise-\(noisePublicKey.base64EncodedString())" + if let data = nostrPubkey.data(using: .utf8) { + KeychainHelper.save(key: key, data: data, service: keychainService) + } + } + + /// Get Nostr public key associated with a Noise public key + static func getNostrPublicKey(for noisePublicKey: Data) -> String? { + let key = "nostr-noise-\(noisePublicKey.base64EncodedString())" + guard let data = KeychainHelper.load(key: key, service: keychainService), + let pubkey = String(data: data, encoding: .utf8) else { + return nil + } + return pubkey + } + + /// Clear all Nostr identity associations and current identity + static func clearAllAssociations() { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + kSecMatchLimit as String: kSecMatchLimitAll, + kSecReturnAttributes as String: true + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecSuccess, let items = result as? [[String: Any]] { + for item in items { + var deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService + ] + if let account = item[kSecAttrAccount as String] as? String { + deleteQuery[kSecAttrAccount as String] = account + } + SecItemDelete(deleteQuery as CFDictionary) + } + } else if status == errSecItemNotFound { + // nothing persisted; no action needed + } + + deviceSeedCache = nil + } + + // MARK: - Per-Geohash Identities (Location Channels) + + /// Returns a stable device seed used to derive unlinkable per-geohash identities. + /// Stored only on device keychain. + private static func getOrCreateDeviceSeed() -> Data { + if let cached = deviceSeedCache { return cached } + if let existing = KeychainHelper.load(key: deviceSeedKey, service: keychainService) { + // Migrate to AfterFirstUnlockThisDeviceOnly for stability during lock + KeychainHelper.save(key: deviceSeedKey, data: existing, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly) + deviceSeedCache = existing + return existing + } + var seed = Data(count: 32) + _ = seed.withUnsafeMutableBytes { ptr in + SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!) + } + // Ensure availability after first unlock to prevent unintended rotation when locked + KeychainHelper.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly) + deviceSeedCache = seed + return seed + } + + /// Derive a deterministic, unlinkable Nostr identity for a given geohash. + /// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing + /// if the candidate is not a valid secp256k1 private key. + static func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity { + // Check cache first to avoid repeated crypto + keychain I/O during view rendering + cacheLock.lock() + if let cached = derivedIdentityCache[geohash] { + cacheLock.unlock() + return cached + } + cacheLock.unlock() + + let seed = getOrCreateDeviceSeed() + guard let msg = geohash.data(using: .utf8) else { + throw NSError(domain: "NostrIdentity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid geohash string"]) + } + + func candidateKey(iteration: UInt32) -> Data { + var input = Data(msg) + var iterBE = iteration.bigEndian + withUnsafeBytes(of: &iterBE) { bytes in + input.append(contentsOf: bytes) + } + let code = CryptoKit.HMAC.authenticationCode(for: input, using: SymmetricKey(data: seed)) + return Data(code) + } + + // Try a few iterations to ensure a valid key can be formed + for i in 0..<10 { + let keyData = candidateKey(iteration: UInt32(i)) + if let identity = try? NostrIdentity(privateKeyData: keyData) { + // Cache the result + cacheLock.lock() + derivedIdentityCache[geohash] = identity + cacheLock.unlock() + return identity + } + } + // As a final fallback, hash the seed+msg and try again + let fallback = (seed + msg).sha256Hash() + let identity = try NostrIdentity(privateKeyData: fallback) + + // Cache the result + cacheLock.lock() + derivedIdentityCache[geohash] = identity + cacheLock.unlock() + + return identity + } +} + +// Bech32 encoding for Nostr (minimal implementation) +enum Bech32 { + private static let charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + private static let generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3] + + static func encode(hrp: String, data: Data) throws -> String { + let values = convertBits(from: 8, to: 5, pad: true, data: Array(data)) + let checksum = createChecksum(hrp: hrp, values: values) + let combined = values + checksum + + return hrp + "1" + combined.map { + let index = charset.index(charset.startIndex, offsetBy: Int($0)) + return String(charset[index]) + }.joined() + } + + static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) { + // Find the last occurrence of '1' + guard let separatorIndex = bech32String.lastIndex(of: "1") else { + throw Bech32Error.invalidFormat + } + + let hrp = String(bech32String[..= 6 else { + throw Bech32Error.invalidChecksum + } + + let payloadValues = Array(values.dropLast(6)) + let checksum = Array(values.suffix(6)) + let expectedChecksum = createChecksum(hrp: hrp, values: payloadValues) + + guard checksum == expectedChecksum else { + throw Bech32Error.invalidChecksum + } + + // Convert back to bytes + let bytes = convertBits(from: 5, to: 8, pad: false, data: payloadValues) + return (hrp: hrp, data: Data(bytes)) + } + + enum Bech32Error: Error { + case invalidFormat + case invalidCharacter + case invalidChecksum + } + + private static func convertBits(from: Int, to: Int, pad: Bool, data: [UInt8]) -> [UInt8] { + var acc = 0 + var bits = 0 + var result = [UInt8]() + let maxv = (1 << to) - 1 + + for value in data { + acc = (acc << from) | Int(value) + bits += from + + while bits >= to { + bits -= to + result.append(UInt8((acc >> bits) & maxv)) + } + } + + if pad && bits > 0 { + result.append(UInt8((acc << (to - bits)) & maxv)) + } + + return result + } + + private static func createChecksum(hrp: String, values: [UInt8]) -> [UInt8] { + let checksumValues = hrpExpand(hrp) + values + [0, 0, 0, 0, 0, 0] + let polymod = polymod(checksumValues) ^ 1 + var checksum = [UInt8]() + + for i in 0..<6 { + checksum.append(UInt8((polymod >> (5 * (5 - i))) & 31)) + } + + return checksum + } + + private static func hrpExpand(_ hrp: String) -> [UInt8] { + var result = [UInt8]() + for c in hrp { + guard let asciiValue = c.asciiValue else { + return [] // Return empty array for invalid input + } + result.append(UInt8(asciiValue >> 5)) + } + result.append(0) + for c in hrp { + guard let asciiValue = c.asciiValue else { + return [] // Return empty array for invalid input + } + result.append(UInt8(asciiValue & 31)) + } + return result + } + + private static func polymod(_ values: [UInt8]) -> Int { + var chk = 1 + for value in values { + let b = chk >> 25 + chk = (chk & 0x1ffffff) << 5 ^ Int(value) + for i in 0..<5 { + if (b >> i) & 1 == 1 { + chk ^= generator[i] + } + } + } + return chk + } +} + +// Data hex encoding extension moved to BinaryEncodingUtils.swift to avoid duplication