mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 22:05:21 +00:00
Merge branch 'main' into feat/472-optimize-message-deduplicator
This commit is contained in:
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,9 +12,9 @@ final class NostrIdentityBridge {
|
|||||||
private var derivedIdentityCache: [String: NostrIdentity] = [:]
|
private var derivedIdentityCache: [String: NostrIdentity] = [:]
|
||||||
private let cacheLock = NSLock()
|
private let cacheLock = NSLock()
|
||||||
|
|
||||||
private let keychain: KeychainHelperProtocol
|
private let keychain: KeychainManagerProtocol
|
||||||
|
|
||||||
init(keychain: KeychainHelperProtocol = KeychainHelper()) {
|
init(keychain: KeychainManagerProtocol = KeychainManager()) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,14 +23,36 @@ extension Data {
|
|||||||
return digest.map { String(format: "%02x", $0) }.joined()
|
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) {
|
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 data = Data(capacity: len)
|
||||||
var index = hexString.startIndex
|
var index = hex.startIndex
|
||||||
|
|
||||||
for _ in 0..<len {
|
for _ in 0..<len {
|
||||||
let nextIndex = hexString.index(index, offsetBy: 2)
|
let nextIndex = hex.index(index, offsetBy: 2)
|
||||||
guard let byte = UInt8(String(hexString[index..<nextIndex]), radix: 16) else {
|
guard let byte = UInt8(String(hex[index..<nextIndex]), radix: 16) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
data.append(byte)
|
data.append(byte)
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
|
|
||||||
private static let storageKey = "chat.bitchat.favorites"
|
private static let storageKey = "chat.bitchat.favorites"
|
||||||
private static let keychainService = "chat.bitchat.favorites"
|
private static let keychainService = "chat.bitchat.favorites"
|
||||||
private let keychain: KeychainHelperProtocol
|
private let keychain: KeychainManagerProtocol
|
||||||
|
|
||||||
@Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship
|
@Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship
|
||||||
@Published private(set) var mutualFavorites: Set<Data> = []
|
@Published private(set) var mutualFavorites: Set<Data> = []
|
||||||
@@ -36,7 +36,7 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
|
|
||||||
static let shared = FavoritesPersistenceService()
|
static let shared = FavoritesPersistenceService()
|
||||||
|
|
||||||
init(keychain: KeychainHelperProtocol = KeychainHelper()) {
|
init(keychain: KeychainManagerProtocol = KeychainManager()) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
loadFavorites()
|
loadFavorites()
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ protocol KeychainManagerProtocol {
|
|||||||
func secureClear(_ string: inout String)
|
func secureClear(_ string: inout String)
|
||||||
|
|
||||||
func verifyIdentityKeyExists() -> Bool
|
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 {
|
final class KeychainManager: KeychainManagerProtocol {
|
||||||
@@ -314,4 +322,49 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
let key = "identity_noiseStaticKey"
|
let key = "identity_noiseStaticKey"
|
||||||
return retrieveData(forKey: key) != nil
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import Foundation
|
|||||||
|
|
||||||
final class PreviewKeychainManager: KeychainManagerProtocol {
|
final class PreviewKeychainManager: KeychainManagerProtocol {
|
||||||
private var storage: [String: Data] = [:]
|
private var storage: [String: Data] = [:]
|
||||||
|
private var serviceStorage: [String: [String: Data]] = [:]
|
||||||
init() {}
|
init() {}
|
||||||
|
|
||||||
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
||||||
@@ -28,6 +29,7 @@ final class PreviewKeychainManager: KeychainManagerProtocol {
|
|||||||
|
|
||||||
func deleteAllKeychainData() -> Bool {
|
func deleteAllKeychainData() -> Bool {
|
||||||
storage.removeAll()
|
storage.removeAll()
|
||||||
|
serviceStorage.removeAll()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,4 +40,21 @@ final class PreviewKeychainManager: KeychainManagerProtocol {
|
|||||||
func verifyIdentityKeyExists() -> Bool {
|
func verifyIdentityKeyExists() -> Bool {
|
||||||
storage["identity_noiseStaticKey"] != nil
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import Foundation
|
|||||||
|
|
||||||
final class MockKeychain: KeychainManagerProtocol {
|
final class MockKeychain: KeychainManagerProtocol {
|
||||||
private var storage: [String: Data] = [:]
|
private var storage: [String: Data] = [:]
|
||||||
|
private var serviceStorage: [String: [String: Data]] = [:]
|
||||||
|
|
||||||
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
||||||
storage[key] = keyData
|
storage[key] = keyData
|
||||||
@@ -28,11 +29,11 @@ final class MockKeychain: KeychainManagerProtocol {
|
|||||||
|
|
||||||
func deleteAllKeychainData() -> Bool {
|
func deleteAllKeychainData() -> Bool {
|
||||||
storage.removeAll()
|
storage.removeAll()
|
||||||
|
serviceStorage.removeAll()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func secureClear(_ data: inout Data) {
|
func secureClear(_ data: inout Data) {
|
||||||
//
|
|
||||||
data = Data()
|
data = Data()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,22 +44,24 @@ final class MockKeychain: KeychainManagerProtocol {
|
|||||||
func verifyIdentityKeyExists() -> Bool {
|
func verifyIdentityKeyExists() -> Bool {
|
||||||
storage["identity_noiseStaticKey"] != nil
|
storage["identity_noiseStaticKey"] != nil
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
final class MockKeychainHelper: KeychainHelperProtocol {
|
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
|
||||||
private typealias Service = String
|
|
||||||
private typealias Key = String
|
|
||||||
private var storage: [Service: [Key: Data]] = [:]
|
|
||||||
|
|
||||||
func save(key: String, data: Data, service: String, accessible: CFString?) {
|
func save(key: String, data: Data, service: String, accessible: CFString?) {
|
||||||
storage[service]?[key] = data
|
if serviceStorage[service] == nil {
|
||||||
|
serviceStorage[service] = [:]
|
||||||
|
}
|
||||||
|
serviceStorage[service]?[key] = data
|
||||||
}
|
}
|
||||||
|
|
||||||
func load(key: String, service: String) -> Data? {
|
func load(key: String, service: String) -> Data? {
|
||||||
storage[service]?[key]
|
serviceStorage[service]?[key]
|
||||||
}
|
}
|
||||||
|
|
||||||
func delete(key: String, service: String) {
|
func delete(key: String, service: String) {
|
||||||
storage[service]?.removeValue(forKey: key)
|
serviceStorage[service]?.removeValue(forKey: key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Typealias for backwards compatibility with tests using MockKeychainHelper
|
||||||
|
typealias MockKeychainHelper = MockKeychain
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user