diff --git a/bitchat/Nostr/KeychainHelper.swift b/bitchat/Nostr/KeychainHelper.swift deleted file mode 100644 index 01de3dc4..00000000 --- a/bitchat/Nostr/KeychainHelper.swift +++ /dev/null @@ -1,50 +0,0 @@ -import Foundation - -protocol KeychainHelperProtocol { - func save(key: String, data: Data, service: String, accessible: CFString?) - func load(key: String, service: String) -> Data? - func delete(key: String, service: String) -} - -/// Keychain helper for secure storage -struct KeychainHelper: KeychainHelperProtocol { - func save(key: String, data: Data, service: String, accessible: CFString? = nil) { - var query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: key, - kSecValueData as String: data - ] - if let accessible = accessible { - query[kSecAttrAccessible as String] = accessible - } - - SecItemDelete(query as CFDictionary) - SecItemAdd(query as CFDictionary, nil) - } - - func load(key: String, service: String) -> 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) - - guard status == errSecSuccess else { return nil } - return result as? Data - } - - func delete(key: String, service: String) { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: key - ] - - SecItemDelete(query as CFDictionary) - } -} diff --git a/bitchat/Nostr/NostrIdentityBridge.swift b/bitchat/Nostr/NostrIdentityBridge.swift index 37e20779..01d929e0 100644 --- a/bitchat/Nostr/NostrIdentityBridge.swift +++ b/bitchat/Nostr/NostrIdentityBridge.swift @@ -12,9 +12,9 @@ final class NostrIdentityBridge { private var derivedIdentityCache: [String: NostrIdentity] = [:] private let cacheLock = NSLock() - private let keychain: KeychainHelperProtocol + private let keychain: KeychainManagerProtocol - init(keychain: KeychainHelperProtocol = KeychainHelper()) { + init(keychain: KeychainManagerProtocol = KeychainManager()) { self.keychain = keychain } diff --git a/bitchat/Protocols/BinaryEncodingUtils.swift b/bitchat/Protocols/BinaryEncodingUtils.swift index 10c14c99..1dcfe7c1 100644 --- a/bitchat/Protocols/BinaryEncodingUtils.swift +++ b/bitchat/Protocols/BinaryEncodingUtils.swift @@ -23,20 +23,42 @@ extension Data { return digest.map { String(format: "%02x", $0) }.joined() } + /// Initialize Data from a hex string. + /// - Parameter hexString: A hex string, optionally prefixed with "0x" or "0X". + /// Whitespace is trimmed. Must have even length after prefix removal. + /// - Returns: nil if the string has odd length or contains invalid hex characters. init?(hexString: String) { - let len = hexString.count / 2 + var hex = hexString.trimmingCharacters(in: .whitespaces) + + // Remove optional 0x prefix + if hex.hasPrefix("0x") || hex.hasPrefix("0X") { + hex = String(hex.dropFirst(2)) + } + + // Reject odd-length strings + guard hex.count % 2 == 0 else { + return nil + } + + // Reject empty strings + guard !hex.isEmpty else { + self = Data() + return + } + + let len = hex.count / 2 var data = Data(capacity: len) - var index = hexString.startIndex - + var index = hex.startIndex + for _ in 0.. relationship @Published private(set) var mutualFavorites: Set = [] @@ -35,8 +35,8 @@ final class FavoritesPersistenceService: ObservableObject { private var cancellables = Set() static let shared = FavoritesPersistenceService() - - init(keychain: KeychainHelperProtocol = KeychainHelper()) { + + init(keychain: KeychainManagerProtocol = KeychainManager()) { self.keychain = keychain loadFavorites() diff --git a/bitchat/Services/KeychainManager.swift b/bitchat/Services/KeychainManager.swift index be6ed784..c6af3d33 100644 --- a/bitchat/Services/KeychainManager.swift +++ b/bitchat/Services/KeychainManager.swift @@ -15,11 +15,19 @@ protocol KeychainManagerProtocol { func getIdentityKey(forKey key: String) -> Data? func deleteIdentityKey(forKey key: String) -> Bool func deleteAllKeychainData() -> Bool - + func secureClear(_ data: inout Data) func secureClear(_ string: inout String) - + func verifyIdentityKeyExists() -> Bool + + // MARK: - Generic Data Storage (consolidated from KeychainHelper) + /// Save data with a custom service name + func save(key: String, data: Data, service: String, accessible: CFString?) + /// Load data from a custom service + func load(key: String, service: String) -> Data? + /// Delete data from a custom service + func delete(key: String, service: String) } final class KeychainManager: KeychainManagerProtocol { @@ -309,9 +317,54 @@ final class KeychainManager: KeychainManagerProtocol { } // MARK: - Debug - + func verifyIdentityKeyExists() -> Bool { let key = "identity_noiseStaticKey" return retrieveData(forKey: key) != nil } + + // MARK: - Generic Data Storage (consolidated from KeychainHelper) + + /// Save data with a custom service name + func save(key: String, data: Data, service customService: String, accessible: CFString?) { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: customService, + kSecAttrAccount as String: key, + kSecValueData as String: data + ] + if let accessible = accessible { + query[kSecAttrAccessible as String] = accessible + } + + SecItemDelete(query as CFDictionary) + SecItemAdd(query as CFDictionary, nil) + } + + /// Load data from a custom service + func load(key: String, service customService: String) -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: customService, + kSecAttrAccount as String: key, + kSecReturnData as String: true + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess else { return nil } + return result as? Data + } + + /// Delete data from a custom service + func delete(key: String, service customService: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: customService, + kSecAttrAccount as String: key + ] + + SecItemDelete(query as CFDictionary) + } } diff --git a/bitchat/_PreviewHelpers/PreviewKeychainManager.swift b/bitchat/_PreviewHelpers/PreviewKeychainManager.swift index 07542c4e..490fc69d 100644 --- a/bitchat/_PreviewHelpers/PreviewKeychainManager.swift +++ b/bitchat/_PreviewHelpers/PreviewKeychainManager.swift @@ -10,32 +10,51 @@ import Foundation final class PreviewKeychainManager: KeychainManagerProtocol { private var storage: [String: Data] = [:] + private var serviceStorage: [String: [String: Data]] = [:] init() {} - + func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool { storage[key] = keyData return true } - + func getIdentityKey(forKey key: String) -> Data? { storage[key] } - + func deleteIdentityKey(forKey key: String) -> Bool { storage.removeValue(forKey: key) return true } - + func deleteAllKeychainData() -> Bool { storage.removeAll() + serviceStorage.removeAll() return true } - + func secureClear(_ data: inout Data) {} - + func secureClear(_ string: inout String) {} - + func verifyIdentityKeyExists() -> Bool { storage["identity_noiseStaticKey"] != nil } + + // MARK: - Generic Data Storage (consolidated from KeychainHelper) + + func save(key: String, data: Data, service: String, accessible: CFString?) { + if serviceStorage[service] == nil { + serviceStorage[service] = [:] + } + serviceStorage[service]?[key] = data + } + + func load(key: String, service: String) -> Data? { + serviceStorage[service]?[key] + } + + func delete(key: String, service: String) { + serviceStorage[service]?.removeValue(forKey: key) + } } diff --git a/bitchatTests/Mocks/MockKeychain.swift b/bitchatTests/Mocks/MockKeychain.swift index f8718c82..c3ffa9d3 100644 --- a/bitchatTests/Mocks/MockKeychain.swift +++ b/bitchatTests/Mocks/MockKeychain.swift @@ -11,54 +11,57 @@ import Foundation final class MockKeychain: KeychainManagerProtocol { private var storage: [String: Data] = [:] - + private var serviceStorage: [String: [String: Data]] = [:] + func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool { storage[key] = keyData return true } - + func getIdentityKey(forKey key: String) -> Data? { storage[key] } - + func deleteIdentityKey(forKey key: String) -> Bool { storage.removeValue(forKey: key) return true } - + func deleteAllKeychainData() -> Bool { storage.removeAll() + serviceStorage.removeAll() return true } - + func secureClear(_ data: inout Data) { - // data = Data() } - + func secureClear(_ string: inout String) { string = "" } - + func verifyIdentityKeyExists() -> Bool { storage["identity_noiseStaticKey"] != nil } + + // MARK: - Generic Data Storage (consolidated from KeychainHelper) + + func save(key: String, data: Data, service: String, accessible: CFString?) { + if serviceStorage[service] == nil { + serviceStorage[service] = [:] + } + serviceStorage[service]?[key] = data + } + + func load(key: String, service: String) -> Data? { + serviceStorage[service]?[key] + } + + func delete(key: String, service: String) { + serviceStorage[service]?.removeValue(forKey: key) + } } -final class MockKeychainHelper: KeychainHelperProtocol { - private typealias Service = String - private typealias Key = String - private var storage: [Service: [Key: Data]] = [:] - - func save(key: String, data: Data, service: String, accessible: CFString?) { - storage[service]?[key] = data - } - - func load(key: String, service: String) -> Data? { - storage[service]?[key] - } - - func delete(key: String, service: String) { - storage[service]?.removeValue(forKey: key) - } -} +/// Typealias for backwards compatibility with tests using MockKeychainHelper +typealias MockKeychainHelper = MockKeychain diff --git a/bitchatTests/Utils/HexStringTests.swift b/bitchatTests/Utils/HexStringTests.swift new file mode 100644 index 00000000..fa2581f4 --- /dev/null +++ b/bitchatTests/Utils/HexStringTests.swift @@ -0,0 +1,108 @@ +// +// HexStringTests.swift +// bitchatTests +// +// Tests for Data(hexString:) hex parsing +// + +import Testing +import Foundation +@testable import bitchat + +struct HexStringTests { + + // MARK: - Valid Hex Strings + + @Test func validHexString() { + let data = Data(hexString: "0102030405") + #expect(data == Data([0x01, 0x02, 0x03, 0x04, 0x05])) + } + + @Test func validHexStringUppercase() { + let data = Data(hexString: "AABBCCDD") + #expect(data == Data([0xAA, 0xBB, 0xCC, 0xDD])) + } + + @Test func validHexStringMixedCase() { + let data = Data(hexString: "aAbBcCdD") + #expect(data == Data([0xAA, 0xBB, 0xCC, 0xDD])) + } + + @Test func validHexStringWith0xPrefix() { + let data = Data(hexString: "0x0102030405") + #expect(data == Data([0x01, 0x02, 0x03, 0x04, 0x05])) + } + + @Test func validHexStringWith0XPrefix() { + let data = Data(hexString: "0XAABBCCDD") + #expect(data == Data([0xAA, 0xBB, 0xCC, 0xDD])) + } + + @Test func validHexStringWithWhitespace() { + let data = Data(hexString: " 0102030405 ") + #expect(data == Data([0x01, 0x02, 0x03, 0x04, 0x05])) + } + + @Test func validHexStringWith0xPrefixAndWhitespace() { + let data = Data(hexString: " 0x0102030405 ") + #expect(data == Data([0x01, 0x02, 0x03, 0x04, 0x05])) + } + + @Test func emptyHexString() { + let data = Data(hexString: "") + #expect(data == Data()) + } + + @Test func emptyHexStringWithWhitespace() { + let data = Data(hexString: " ") + #expect(data == Data()) + } + + @Test func emptyHexStringWith0xPrefix() { + let data = Data(hexString: "0x") + #expect(data == Data()) + } + + // MARK: - Invalid Hex Strings + + @Test func oddLengthHexStringReturnsNil() { + let data = Data(hexString: "012") + #expect(data == nil) + } + + @Test func oddLengthHexStringWith0xPrefixReturnsNil() { + let data = Data(hexString: "0x012") + #expect(data == nil) + } + + @Test func invalidCharactersReturnNil() { + let data = Data(hexString: "GHIJ") + #expect(data == nil) + } + + @Test func mixedValidAndInvalidCharactersReturnNil() { + let data = Data(hexString: "01GH") + #expect(data == nil) + } + + @Test func specialCharactersReturnNil() { + let data = Data(hexString: "01-02") + #expect(data == nil) + } + + // MARK: - Round Trip Tests + + @Test func roundTripConversion() { + let original = Data([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]) + let hexString = original.hexEncodedString() + let roundTripped = Data(hexString: hexString) + #expect(roundTripped == original) + } + + @Test func roundTripConversionWith0xPrefix() { + let original = Data([0xDE, 0xAD, 0xBE, 0xEF]) + let hexString = "0x" + original.hexEncodedString() + let roundTripped = Data(hexString: hexString) + #expect(roundTripped == original) + } +}