diff --git a/bitchat/Models/BitchatMessage+Media.swift b/bitchat/Models/BitchatMessage+Media.swift index aa2a59f0..5706654c 100644 --- a/bitchat/Models/BitchatMessage+Media.swift +++ b/bitchat/Models/BitchatMessage+Media.swift @@ -6,6 +6,7 @@ // For more information, see // +import BitFoundation import Foundation extension BitchatMessage { diff --git a/bitchat/Noise/NoiseProtocol.swift b/bitchat/Noise/NoiseProtocol.swift index faa18f31..665a5001 100644 --- a/bitchat/Noise/NoiseProtocol.swift +++ b/bitchat/Noise/NoiseProtocol.swift @@ -78,6 +78,7 @@ /// import BitLogger +import BitFoundation import Foundation import CryptoKit diff --git a/bitchat/Nostr/NostrIdentityBridge.swift b/bitchat/Nostr/NostrIdentityBridge.swift index 01d929e0..c9fa8014 100644 --- a/bitchat/Nostr/NostrIdentityBridge.swift +++ b/bitchat/Nostr/NostrIdentityBridge.swift @@ -1,3 +1,4 @@ +import BitFoundation import Foundation import CryptoKit diff --git a/bitchat/Protocols/BitchatFilePacket.swift b/bitchat/Protocols/BitchatFilePacket.swift index 26a27ae2..6002744d 100644 --- a/bitchat/Protocols/BitchatFilePacket.swift +++ b/bitchat/Protocols/BitchatFilePacket.swift @@ -7,6 +7,7 @@ // import Foundation +import BitFoundation import BitLogger /// TLV payload for Bluetooth mesh file transfers (voice notes, images, generic files). diff --git a/bitchat/Protocols/BitchatProtocol.swift b/bitchat/Protocols/BitchatProtocol.swift index a13f3056..4ebe6509 100644 --- a/bitchat/Protocols/BitchatProtocol.swift +++ b/bitchat/Protocols/BitchatProtocol.swift @@ -62,40 +62,6 @@ import Foundation import CoreBluetooth import BitFoundation -// MARK: - Message Types - -/// Simplified BitChat protocol message types. -/// Reduced from 24 types to just 6 essential ones. -/// All private communication metadata (receipts, status) is embedded in noiseEncrypted payloads. -enum MessageType: UInt8 { - // Public messages (unencrypted) - case announce = 0x01 // "I'm here" with nickname - case message = 0x02 // Public chat message - case leave = 0x03 // "I'm leaving" - case requestSync = 0x21 // GCS filter-based sync request (local-only) - - // Noise encryption - case noiseHandshake = 0x10 // Handshake (init or response determined by payload) - case noiseEncrypted = 0x11 // All encrypted payloads (messages, receipts, etc.) - - // Fragmentation (simplified) - case fragment = 0x20 // Single fragment type for large messages - case fileTransfer = 0x22 // Binary file/audio/image payloads - - var description: String { - switch self { - case .announce: return "announce" - case .message: return "message" - case .leave: return "leave" - case .requestSync: return "requestSync" - case .noiseHandshake: return "noiseHandshake" - case .noiseEncrypted: return "noiseEncrypted" - case .fragment: return "fragment" - case .fileTransfer: return "fileTransfer" - } - } -} - // MARK: - Noise Payload Types /// Types of payloads embedded within noiseEncrypted messages. @@ -132,35 +98,6 @@ enum LazyHandshakeState { case failed(Error) // Handshake failed } -// MARK: - Delivery Status - -// Delivery status for messages -enum DeliveryStatus: Codable, Equatable, Hashable { - case sending - case sent // Left our device - case delivered(to: String, at: Date) // Confirmed by recipient - case read(by: String, at: Date) // Seen by recipient - case failed(reason: String) - case partiallyDelivered(reached: Int, total: Int) // For rooms - - var displayText: String { - switch self { - case .sending: - return "Sending..." - case .sent: - return "Sent" - case .delivered(let nickname, _): - return "Delivered to \(nickname)" - case .read(let nickname, _): - return "Read by \(nickname)" - case .failed(let reason): - return "Failed: \(reason)" - case .partiallyDelivered(let reached, let total): - return "Delivered to \(reached)/\(total)" - } - } -} - // MARK: - Delegate Protocol protocol BitchatDelegate: AnyObject { diff --git a/bitchat/Services/KeychainManager.swift b/bitchat/Services/KeychainManager.swift index 574bdf9f..e469f98d 100644 --- a/bitchat/Services/KeychainManager.swift +++ b/bitchat/Services/KeychainManager.swift @@ -7,76 +7,10 @@ // import BitLogger +import BitFoundation import Foundation import Security -// MARK: - Keychain Error Types -// BCH-01-009: Proper error classification to distinguish expected states from critical failures - -/// Result of a keychain read operation with proper error classification -enum KeychainReadResult { - case success(Data) - case itemNotFound // Expected: key doesn't exist yet - case accessDenied // Critical: app lacks keychain access - case deviceLocked // Recoverable: device is locked - case authenticationFailed // Recoverable: biometric/passcode failed - case otherError(OSStatus) // Unexpected error - - var isRecoverableError: Bool { - switch self { - case .deviceLocked, .authenticationFailed: - return true - default: - return false - } - } -} - -/// Result of a keychain save operation with proper error classification -enum KeychainSaveResult { - case success - case duplicateItem // Can retry with update - case accessDenied // Critical: app lacks keychain access - case deviceLocked // Recoverable: device is locked - case storageFull // Critical: no space available - case otherError(OSStatus) - - var isRecoverableError: Bool { - switch self { - case .duplicateItem, .deviceLocked: - return true - default: - return false - } - } -} - -protocol KeychainManagerProtocol { - func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool - 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 - - // BCH-01-009: Methods with proper error classification - /// Get identity key with detailed result for error handling - func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult - /// Save identity key with detailed result for error handling - func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult - - // 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 { // Use consistent service name for all keychain items private let service = BitchatApp.bundleID diff --git a/bitchat/Services/NotificationStreamAssembler.swift b/bitchat/Services/NotificationStreamAssembler.swift index cbef4506..0a46ffbd 100644 --- a/bitchat/Services/NotificationStreamAssembler.swift +++ b/bitchat/Services/NotificationStreamAssembler.swift @@ -7,6 +7,7 @@ // import BitLogger +import BitFoundation import Foundation struct NotificationStreamAssembler { diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index 5265af59..6153c266 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -133,9 +133,6 @@ enum TransportConfig { static let nostrShortKeyDisplayLength: Int = 8 static let nostrConvKeyPrefixLength: Int = 16 - // Compression - static let compressionThresholdBytes: Int = 100 - // Message deduplication static let messageDedupMaxAgeSeconds: TimeInterval = 300 static let messageDedupMaxCount: Int = 1000 diff --git a/bitchat/Sync/PacketIdUtil.swift b/bitchat/Sync/PacketIdUtil.swift index 08b7910d..3c6c0bdc 100644 --- a/bitchat/Sync/PacketIdUtil.swift +++ b/bitchat/Sync/PacketIdUtil.swift @@ -1,3 +1,4 @@ +import struct BitFoundation.BitchatPacket import Foundation import CryptoKit diff --git a/bitchat/Sync/SyncTypeFlags.swift b/bitchat/Sync/SyncTypeFlags.swift index 15e22d89..19261314 100644 --- a/bitchat/Sync/SyncTypeFlags.swift +++ b/bitchat/Sync/SyncTypeFlags.swift @@ -1,3 +1,4 @@ +import BitFoundation import Foundation /// Bitfield describing which message types are covered by a REQUEST_SYNC round. diff --git a/bitchat/ViewModels/PublicMessagePipeline.swift b/bitchat/ViewModels/PublicMessagePipeline.swift index a1b794e8..516160e9 100644 --- a/bitchat/ViewModels/PublicMessagePipeline.swift +++ b/bitchat/ViewModels/PublicMessagePipeline.swift @@ -5,6 +5,7 @@ // Handles batching and deduplication of public chat messages before surfacing them to the UI. // +import BitFoundation import Foundation @MainActor diff --git a/bitchat/ViewModels/PublicTimelineStore.swift b/bitchat/ViewModels/PublicTimelineStore.swift index 92a76cc6..6e03382f 100644 --- a/bitchat/ViewModels/PublicTimelineStore.swift +++ b/bitchat/ViewModels/PublicTimelineStore.swift @@ -5,6 +5,7 @@ // Maintains mesh and geohash public timelines with simple caps and helpers. // +import BitFoundation import Foundation struct PublicTimelineStore { diff --git a/bitchat/Views/Components/DeliveryStatusView.swift b/bitchat/Views/Components/DeliveryStatusView.swift index 13c045ee..4f3025ec 100644 --- a/bitchat/Views/Components/DeliveryStatusView.swift +++ b/bitchat/Views/Components/DeliveryStatusView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import BitFoundation struct DeliveryStatusView: View { @Environment(\.colorScheme) private var colorScheme diff --git a/bitchat/Views/Components/TextMessageView.swift b/bitchat/Views/Components/TextMessageView.swift index 194dc002..7b89a781 100644 --- a/bitchat/Views/Components/TextMessageView.swift +++ b/bitchat/Views/Components/TextMessageView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import BitFoundation struct TextMessageView: View { @Environment(\.colorScheme) private var colorScheme: ColorScheme diff --git a/bitchat/Views/Media/MediaMessageView.swift b/bitchat/Views/Media/MediaMessageView.swift index 76669489..0673e522 100644 --- a/bitchat/Views/Media/MediaMessageView.swift +++ b/bitchat/Views/Media/MediaMessageView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import BitFoundation struct MediaMessageView: View { @Environment(\.colorScheme) private var colorScheme diff --git a/bitchat/_PreviewHelpers/BitchatMessage+Preview.swift b/bitchat/_PreviewHelpers/BitchatMessage+Preview.swift index 25a530cd..e57a1471 100644 --- a/bitchat/_PreviewHelpers/BitchatMessage+Preview.swift +++ b/bitchat/_PreviewHelpers/BitchatMessage+Preview.swift @@ -6,6 +6,7 @@ // For more information, see // +import BitFoundation import Foundation extension BitchatMessage { diff --git a/bitchat/_PreviewHelpers/PreviewKeychainManager.swift b/bitchat/_PreviewHelpers/PreviewKeychainManager.swift index 464eaa3d..49e63d3f 100644 --- a/bitchat/_PreviewHelpers/PreviewKeychainManager.swift +++ b/bitchat/_PreviewHelpers/PreviewKeychainManager.swift @@ -6,6 +6,7 @@ // For more information, see // +import BitFoundation import Foundation final class PreviewKeychainManager: KeychainManagerProtocol { diff --git a/bitchatTests/BLEServiceTests.swift b/bitchatTests/BLEServiceTests.swift index 2a32e838..d704f009 100644 --- a/bitchatTests/BLEServiceTests.swift +++ b/bitchatTests/BLEServiceTests.swift @@ -8,7 +8,7 @@ import Testing import CoreBluetooth -import BitFoundation +@testable import BitFoundation // to avoid unnecessary public's @testable import bitchat struct BLEServiceTests { diff --git a/bitchatTests/EndToEnd/PrivateChatE2ETests.swift b/bitchatTests/EndToEnd/PrivateChatE2ETests.swift index 6f5c7891..dc8de7ab 100644 --- a/bitchatTests/EndToEnd/PrivateChatE2ETests.swift +++ b/bitchatTests/EndToEnd/PrivateChatE2ETests.swift @@ -9,7 +9,7 @@ import Testing import CryptoKit import struct Foundation.UUID -import BitFoundation +@testable import BitFoundation // to avoid unnecessary public's @testable import bitchat struct PrivateChatE2ETests { diff --git a/bitchatTests/EndToEnd/PublicChatE2ETests.swift b/bitchatTests/EndToEnd/PublicChatE2ETests.swift index d380c4c5..6672e678 100644 --- a/bitchatTests/EndToEnd/PublicChatE2ETests.swift +++ b/bitchatTests/EndToEnd/PublicChatE2ETests.swift @@ -8,7 +8,7 @@ import Testing import struct Foundation.UUID -import BitFoundation +@testable import BitFoundation // to avoid unnecessary public's @testable import bitchat struct PublicChatE2ETests { diff --git a/bitchatTests/Integration/IntegrationTests.swift b/bitchatTests/Integration/IntegrationTests.swift index 1907cf33..52b4d2de 100644 --- a/bitchatTests/Integration/IntegrationTests.swift +++ b/bitchatTests/Integration/IntegrationTests.swift @@ -9,7 +9,7 @@ import Foundation import CryptoKit import Testing -import BitFoundation +@testable import BitFoundation // to avoid unnecessary public's @testable import bitchat struct IntegrationTests { diff --git a/bitchatTests/Integration/TestNetworkHelper.swift b/bitchatTests/Integration/TestNetworkHelper.swift index 3b633e54..277e1f52 100644 --- a/bitchatTests/Integration/TestNetworkHelper.swift +++ b/bitchatTests/Integration/TestNetworkHelper.swift @@ -8,7 +8,7 @@ import Foundation import CryptoKit -import BitFoundation +@testable import BitFoundation // to avoid unnecessary public's @testable import bitchat final class TestNetworkHelper { diff --git a/bitchatTests/Mocks/MockBLEService.swift b/bitchatTests/Mocks/MockBLEService.swift index 352efa90..e4fb6ae5 100644 --- a/bitchatTests/Mocks/MockBLEService.swift +++ b/bitchatTests/Mocks/MockBLEService.swift @@ -8,7 +8,7 @@ import Foundation import CoreBluetooth -import BitFoundation +@testable import BitFoundation // to avoid unnecessary public's @testable import bitchat /// In-memory BLE test harness used by E2E/Integration tests. diff --git a/bitchatTests/Mocks/MockKeychain.swift b/bitchatTests/Mocks/MockKeychain.swift index 76b32952..2c8d7c32 100644 --- a/bitchatTests/Mocks/MockKeychain.swift +++ b/bitchatTests/Mocks/MockKeychain.swift @@ -7,6 +7,7 @@ // import Foundation +import BitFoundation @testable import bitchat final class MockKeychain: KeychainManagerProtocol { diff --git a/bitchatTests/NoiseEncryptionTests.swift b/bitchatTests/NoiseEncryptionTests.swift new file mode 100644 index 00000000..66996dfc --- /dev/null +++ b/bitchatTests/NoiseEncryptionTests.swift @@ -0,0 +1,78 @@ +// +// NoiseEncryptionTests.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +@testable import bitchat + +struct NoiseEncryptionTests { + @Test func generatesNewIdentityWhenMissing() throws { + let keychain = MockKeychain() + + // Create service with empty keychain - should generate new identity + let service = NoiseEncryptionService(keychain: keychain) + + // Should have generated and saved keys + #expect(service.getStaticPublicKeyData().count == 32) + #expect(service.getSigningPublicKeyData().count == 32) + + // Keys should be persisted + let noiseKeyResult = keychain.getIdentityKeyWithResult(forKey: "noiseStaticKey") + switch noiseKeyResult { + case .success: + // Expected - key was saved + break + default: + throw KeychainTestError("Expected noise key to be saved") + } + } + + @Test func loadsExistingIdentity() throws { + let keychain = MockKeychain() + + // Create first service to generate identity + let service1 = NoiseEncryptionService(keychain: keychain) + let originalPublicKey = service1.getStaticPublicKeyData() + let originalSigningKey = service1.getSigningPublicKeyData() + + // Create second service - should load same identity + let service2 = NoiseEncryptionService(keychain: keychain) + + #expect(service2.getStaticPublicKeyData() == originalPublicKey) + #expect(service2.getSigningPublicKeyData() == originalSigningKey) + } + + @Test func handlesAccessDeniedGracefully() throws { + let keychain = MockKeychain() + keychain.simulatedReadError = .accessDenied + + // Service should still initialize with ephemeral key + let service = NoiseEncryptionService(keychain: keychain) + + // Should have an identity (ephemeral) + #expect(service.getStaticPublicKeyData().count == 32) + #expect(service.getSigningPublicKeyData().count == 32) + } + + @Test func handlesDeviceLockedGracefully() throws { + let keychain = MockKeychain() + keychain.simulatedReadError = .deviceLocked + + // Service should still initialize with ephemeral key + let service = NoiseEncryptionService(keychain: keychain) + + // Should have an identity (ephemeral) + #expect(service.getStaticPublicKeyData().count == 32) + } +} + +// TODO: Reuse +private struct KeychainTestError: Error, CustomStringConvertible { + let message: String + init(_ message: String) { self.message = message } + var description: String { message } +} diff --git a/bitchatTests/NotificationStreamAssemblerTests.swift b/bitchatTests/NotificationStreamAssemblerTests.swift index fdd37255..7d78fb0a 100644 --- a/bitchatTests/NotificationStreamAssemblerTests.swift +++ b/bitchatTests/NotificationStreamAssemblerTests.swift @@ -1,5 +1,6 @@ import Testing import Foundation +import BitFoundation @testable import bitchat struct NotificationStreamAssemblerTests { diff --git a/bitchatTests/Protocols/BinaryEncodingUtilsTests.swift b/bitchatTests/Protocols/BinaryEncodingUtilsTests.swift index 0d5fa442..09047b9a 100644 --- a/bitchatTests/Protocols/BinaryEncodingUtilsTests.swift +++ b/bitchatTests/Protocols/BinaryEncodingUtilsTests.swift @@ -1,5 +1,6 @@ import Foundation import XCTest +@testable import BitFoundation // to avoid unnecessary public's @testable import bitchat final class BinaryEncodingUtilsTests: XCTestCase { diff --git a/bitchatTests/PublicMessagePipelineTests.swift b/bitchatTests/PublicMessagePipelineTests.swift index dd716cdc..9bf0a5cf 100644 --- a/bitchatTests/PublicMessagePipelineTests.swift +++ b/bitchatTests/PublicMessagePipelineTests.swift @@ -7,6 +7,7 @@ import Testing import Foundation +import BitFoundation @testable import bitchat @MainActor diff --git a/localPackages/BitFoundation/Package.swift b/localPackages/BitFoundation/Package.swift index 927ce283..a1de1be1 100644 --- a/localPackages/BitFoundation/Package.swift +++ b/localPackages/BitFoundation/Package.swift @@ -14,9 +14,15 @@ let package = Package( targets: ["BitFoundation"] ) ], + dependencies: [ + .package(path: "../BitLogger") + ], targets: [ .target( name: "BitFoundation", + dependencies: [ + .product(name: "BitLogger", package: "BitLogger"), + ], path: "Sources" ), .testTarget( diff --git a/bitchat/Protocols/BinaryEncodingUtils.swift b/localPackages/BitFoundation/Sources/BitFoundation/BinaryEncodingUtils.swift similarity index 86% rename from bitchat/Protocols/BinaryEncodingUtils.swift rename to localPackages/BitFoundation/Sources/BitFoundation/BinaryEncodingUtils.swift index 8157d6a9..43d3ec5e 100644 --- a/bitchat/Protocols/BinaryEncodingUtils.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/BinaryEncodingUtils.swift @@ -5,15 +5,15 @@ // Binary encoding utilities for efficient protocol messages // -import Foundation -import BitFoundation +import struct Foundation.Data +import struct Foundation.Date // MARK: - Binary Encoding Utilities extension Data { // MARK: Writing - @inlinable mutating func appendUInt8(_ value: UInt8) { + @inlinable public mutating func appendUInt8(_ value: UInt8) { self.append(value) } @@ -35,7 +35,7 @@ extension Data { } } - mutating func appendString(_ string: String, maxLength: Int = 255) { + public mutating func appendString(_ string: String, maxLength: Int = 255) { guard let data = string.data(using: .utf8) else { return } let length = Swift.min(data.count, maxLength) @@ -48,7 +48,7 @@ extension Data { self.append(data.prefix(length)) } - mutating func appendData(_ data: Data, maxLength: Int = 65535) { + public mutating func appendData(_ data: Data, maxLength: Int = 65535) { let length = Swift.min(data.count, maxLength) if maxLength <= 255 { @@ -60,12 +60,12 @@ extension Data { self.append(data.prefix(length)) } - mutating func appendDate(_ date: Date) { + public mutating func appendDate(_ date: Date) { let timestamp = UInt64(date.timeIntervalSince1970 * 1000) // milliseconds self.appendUInt64(timestamp) } - mutating func appendUUID(_ uuid: String) { + public mutating func appendUUID(_ uuid: String) { // Convert UUID string to 16 bytes var uuidData = Data(count: 16) @@ -86,7 +86,7 @@ extension Data { // MARK: Reading - @inlinable func readUInt8(at offset: inout Int) -> UInt8? { + @inlinable public func readUInt8(at offset: inout Int) -> UInt8? { guard offset >= 0 && offset < self.count else { return nil } let value = self[offset] offset += 1 @@ -120,7 +120,7 @@ extension Data { return value } - func readString(at offset: inout Int, maxLength: Int = 255) -> String? { + public func readString(at offset: inout Int, maxLength: Int = 255) -> String? { let length: Int if maxLength <= 255 { @@ -139,7 +139,7 @@ extension Data { return String(data: stringData, encoding: .utf8) } - func readData(at offset: inout Int, maxLength: Int = 65535) -> Data? { + public func readData(at offset: inout Int, maxLength: Int = 65535) -> Data? { let length: Int if maxLength <= 255 { @@ -158,12 +158,12 @@ extension Data { return data } - func readDate(at offset: inout Int) -> Date? { + public func readDate(at offset: inout Int) -> Date? { guard let timestamp = readUInt64(at: &offset) else { return nil } return Date(timeIntervalSince1970: Double(timestamp) / 1000.0) } - func readUUID(at offset: inout Int) -> String? { + public func readUUID(at offset: inout Int) -> String? { guard offset + 16 <= self.count else { return nil } let uuidData = self[offset.. Data? { + public func readFixedBytes(at offset: inout Int, count: Int) -> Data? { guard offset + count <= self.count else { return nil } let data = self[offset.. Data { - // Find the first null byte - if let nullIndex = self.firstIndex(of: 0) { - return self.prefix(nullIndex) - } - return self - } -} +import struct Foundation.Data +import class Foundation.NSData +private import BitLogger /// Implements binary encoding and decoding for BitChat protocol messages. /// Provides static methods for converting between BitchatPacket objects and /// their binary wire format representation. /// - Note: All multi-byte values use network byte order (big-endian) -struct BinaryProtocol { - static let v1HeaderSize = 14 +public struct BinaryProtocol { + public static let v1HeaderSize = 14 static let v2HeaderSize = 16 - static let senderIDSize = 8 - static let recipientIDSize = 8 - static let signatureSize = 64 + public static let senderIDSize = 8 + public static let recipientIDSize = 8 + public static let signatureSize = 64 // Field offsets within packet header - struct Offsets { + public struct Offsets { static let version = 0 static let type = 1 static let ttl = 2 static let timestamp = 3 - static let flags = 11 // After version(1) + type(1) + ttl(1) + timestamp(8) + public static let flags = 11 // After version(1) + type(1) + ttl(1) + timestamp(8) } - static func headerSize(for version: UInt8) -> Int? { + public static func headerSize(for version: UInt8) -> Int? { switch version { case 1: return v1HeaderSize case 2: return v2HeaderSize @@ -133,11 +124,11 @@ struct BinaryProtocol { return version == 2 ? 4 : 2 } - struct Flags { - static let hasRecipient: UInt8 = 0x01 - static let hasSignature: UInt8 = 0x02 - static let isCompressed: UInt8 = 0x04 - static let hasRoute: UInt8 = 0x08 + public struct Flags { + public static let hasRecipient: UInt8 = 0x01 + public static let hasSignature: UInt8 = 0x02 + public static let isCompressed: UInt8 = 0x04 + public static let hasRoute: UInt8 = 0x08 static let isRSR: UInt8 = 0x10 } @@ -266,7 +257,7 @@ struct BinaryProtocol { } // Decode binary data to BitchatPacket - static func decode(_ data: Data) -> BitchatPacket? { + public static func decode(_ data: Data) -> BitchatPacket? { // Try decode as-is first (robust when padding wasn't applied) if let pkt = decodeCore(data) { return pkt } // If that fails, try after removing padding diff --git a/bitchat/Models/BitchatMessage.swift b/localPackages/BitFoundation/Sources/BitFoundation/BitchatMessage.swift similarity index 91% rename from bitchat/Models/BitchatMessage.swift rename to localPackages/BitFoundation/Sources/BitFoundation/BitchatMessage.swift index 002858cc..aa91eccc 100644 --- a/bitchat/Models/BitchatMessage.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/BitchatMessage.swift @@ -6,34 +6,39 @@ // For more information, see // -import Foundation -import BitFoundation +import class Foundation.DateFormatter + +import struct Foundation.AttributedString +import struct Foundation.Data +import struct Foundation.Date +import struct Foundation.TimeInterval +import struct Foundation.UUID /// Represents a user-visible message in the BitChat system. /// Handles both broadcast messages and private encrypted messages, /// with support for mentions, replies, and delivery tracking. /// - Note: This is the primary data model for chat messages -final class BitchatMessage: Codable { - let id: String - let sender: String - let content: String - let timestamp: Date - let isRelay: Bool - let originalSender: String? - let isPrivate: Bool - let recipientNickname: String? - let senderPeerID: PeerID? - let mentions: [String]? // Array of mentioned nicknames - var deliveryStatus: DeliveryStatus? // Delivery tracking - +public final class BitchatMessage: Codable { + public let id: String + public let sender: String + public let content: String + public let timestamp: Date + public let isRelay: Bool + public let originalSender: String? + public let isPrivate: Bool + public let recipientNickname: String? + public let senderPeerID: PeerID? + public let mentions: [String]? // Array of mentioned nicknames + public var deliveryStatus: DeliveryStatus? // Delivery tracking + // Cached formatted text (not included in Codable) private var _cachedFormattedText: [String: AttributedString] = [:] - func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? { + public func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? { return _cachedFormattedText["\(isDark)-\(isSelf)"] } - func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool) { + public func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool) { _cachedFormattedText["\(isDark)-\(isSelf)"] = text } @@ -43,7 +48,7 @@ final class BitchatMessage: Codable { case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus } - init( + public init( id: String? = nil, sender: String, content: String, @@ -73,7 +78,7 @@ final class BitchatMessage: Codable { // MARK: - Equatable Conformance extension BitchatMessage: Equatable { - static func == (lhs: BitchatMessage, rhs: BitchatMessage) -> Bool { + public static func == (lhs: BitchatMessage, rhs: BitchatMessage) -> Bool { return lhs.id == rhs.id && lhs.sender == rhs.sender && lhs.content == rhs.content && @@ -331,14 +336,14 @@ extension BitchatMessage { return formatter }() - var formattedTimestamp: String { + public var formattedTimestamp: String { Self.timestampFormatter.string(from: timestamp) } } extension Array where Element == BitchatMessage { /// Filters out empty ones and deduplicate by ID while preserving order (from oldest to newest) - func cleanedAndDeduped() -> [Element] { + public func cleanedAndDeduped() -> [Element] { let arr = filter { $0.content.trimmed.isEmpty == false } guard arr.count > 1 else { return arr diff --git a/bitchat/Models/BitchatPacket.swift b/localPackages/BitFoundation/Sources/BitFoundation/BitchatPacket.swift similarity index 78% rename from bitchat/Models/BitchatPacket.swift rename to localPackages/BitFoundation/Sources/BitFoundation/BitchatPacket.swift index 004f5414..b46a552d 100644 --- a/bitchat/Models/BitchatPacket.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/BitchatPacket.swift @@ -6,26 +6,26 @@ // For more information, see // -import Foundation -import BitFoundation +import struct Foundation.Data +import struct Foundation.Date /// The core packet structure for all BitChat protocol messages. /// Encapsulates all data needed for routing through the mesh network, /// including TTL for hop limiting and optional encryption. /// - Note: Packets larger than BLE MTU (512 bytes) are automatically fragmented -struct BitchatPacket: Codable { +public struct BitchatPacket: Codable { let version: UInt8 - let type: UInt8 - let senderID: Data - let recipientID: Data? - let timestamp: UInt64 - let payload: Data - var signature: Data? - var ttl: UInt8 - var route: [Data]? - var isRSR: Bool + public let type: UInt8 + public let senderID: Data + public let recipientID: Data? + public let timestamp: UInt64 + public let payload: Data + public var signature: Data? + public var ttl: UInt8 + public var route: [Data]? + public var isRSR: Bool - init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil, isRSR: Bool = false) { + public init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil, isRSR: Bool = false) { self.version = version self.type = type self.senderID = senderID @@ -66,18 +66,18 @@ struct BitchatPacket: Codable { BinaryProtocol.encode(self) } - func toBinaryData(padding: Bool = true) -> Data? { + public func toBinaryData(padding: Bool = true) -> Data? { BinaryProtocol.encode(self, padding: padding) } // Backward-compatible helper (defaults to padded encoding) - func toBinaryData() -> Data? { + public func toBinaryData() -> Data? { toBinaryData(padding: true) } /// Create binary representation for signing (without signature and TTL fields) /// TTL is excluded because it changes during packet relay operations - func toBinaryDataForSigning() -> Data? { + public func toBinaryDataForSigning() -> Data? { // Create a copy without signature and with fixed TTL for signing // TTL must be excluded because it changes during relay let unsignedPacket = BitchatPacket( @@ -95,7 +95,7 @@ struct BitchatPacket: Codable { return BinaryProtocol.encode(unsignedPacket) } - static func from(_ data: Data) -> BitchatPacket? { + public static func from(_ data: Data) -> BitchatPacket? { BinaryProtocol.decode(data) } } diff --git a/bitchat/Utils/CompressionUtil.swift b/localPackages/BitFoundation/Sources/BitFoundation/CompressionUtil.swift similarity index 95% rename from bitchat/Utils/CompressionUtil.swift rename to localPackages/BitFoundation/Sources/BitFoundation/CompressionUtil.swift index 0e238ced..5bd7bd2b 100644 --- a/bitchat/Utils/CompressionUtil.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/CompressionUtil.swift @@ -6,12 +6,12 @@ // For more information, see // -import Foundation -import Compression +import struct Foundation.Data +private import Compression struct CompressionUtil { // Compression threshold - don't compress if data is smaller than this - static let compressionThreshold = TransportConfig.compressionThresholdBytes // bytes + static let compressionThreshold = Constants.compressionThresholdBytes // bytes // Compress data using zlib algorithm (most compatible) static func compress(_ data: Data) -> Data? { diff --git a/localPackages/BitFoundation/Sources/BitFoundation/Constants.swift b/localPackages/BitFoundation/Sources/BitFoundation/Constants.swift new file mode 100644 index 00000000..ac043d87 --- /dev/null +++ b/localPackages/BitFoundation/Sources/BitFoundation/Constants.swift @@ -0,0 +1,12 @@ +// +// Constants.swift +// BitFoundation +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +enum Constants { + // Compression + static let compressionThresholdBytes: Int = 100 +} diff --git a/localPackages/BitFoundation/Sources/BitFoundation/DeliveryStatus.swift b/localPackages/BitFoundation/Sources/BitFoundation/DeliveryStatus.swift new file mode 100644 index 00000000..8f06ff74 --- /dev/null +++ b/localPackages/BitFoundation/Sources/BitFoundation/DeliveryStatus.swift @@ -0,0 +1,35 @@ +// +// DeliveryStatus.swift +// BitFoundation +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import struct Foundation.Date + +public enum DeliveryStatus: Codable, Equatable, Hashable { + case sending + case sent // Left our device + case delivered(to: String, at: Date) // Confirmed by recipient + case read(by: String, at: Date) // Seen by recipient + case failed(reason: String) + case partiallyDelivered(reached: Int, total: Int) // For rooms + + public var displayText: String { + switch self { + case .sending: + return "Sending..." + case .sent: + return "Sent" + case .delivered(let nickname, _): + return "Delivered to \(nickname)" + case .read(let nickname, _): + return "Read by \(nickname)" + case .failed(let reason): + return "Failed: \(reason)" + case .partiallyDelivered(let reached, let total): + return "Delivered to \(reached)/\(total)" + } + } +} diff --git a/bitchat/Utils/FileTransferLimits.swift b/localPackages/BitFoundation/Sources/BitFoundation/FileTransferLimits.swift similarity index 72% rename from bitchat/Utils/FileTransferLimits.swift rename to localPackages/BitFoundation/Sources/BitFoundation/FileTransferLimits.swift index e7fd7d27..4c54f79c 100644 --- a/bitchat/Utils/FileTransferLimits.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/FileTransferLimits.swift @@ -1,15 +1,13 @@ -import Foundation - /// Centralized thresholds for Bluetooth file transfers to keep payload sizes sane on constrained radios. -enum FileTransferLimits { +public enum FileTransferLimits { /// Absolute ceiling enforced for any file payload (voice, image, other). - static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB + public static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB /// Voice notes stay small for low-latency relays. - static let maxVoiceNoteBytes: Int = 512 * 1024 // 512 KiB + public static let maxVoiceNoteBytes: Int = 512 * 1024 // 512 KiB /// Compressed images after downscaling should comfortably fit under this budget. - static let maxImageBytes: Int = 512 * 1024 // 512 KiB + public static let maxImageBytes: Int = 512 * 1024 // 512 KiB /// Worst-case size once TLV metadata and binary packet framing are included for the largest payloads. - static let maxFramedFileBytes: Int = { + public static let maxFramedFileBytes: Int = { let maxMetadataBytes = Int(UInt16.max) * 2 // fileName + mimeType TLVs let tlvEnvelopeOverhead = 18 + maxMetadataBytes // TLV tags + lengths + metadata bytes let binaryEnvelopeOverhead = BinaryProtocol.v2HeaderSize @@ -19,7 +17,7 @@ enum FileTransferLimits { return maxPayloadBytes + tlvEnvelopeOverhead + binaryEnvelopeOverhead }() - static func isValidPayload(_ size: Int) -> Bool { + public static func isValidPayload(_ size: Int) -> Bool { size <= maxPayloadBytes } } diff --git a/localPackages/BitFoundation/Sources/BitFoundation/KeychainManagerProtocol.swift b/localPackages/BitFoundation/Sources/BitFoundation/KeychainManagerProtocol.swift new file mode 100644 index 00000000..ea78288e --- /dev/null +++ b/localPackages/BitFoundation/Sources/BitFoundation/KeychainManagerProtocol.swift @@ -0,0 +1,78 @@ +// +// KeychainManagerProtocol.swift +// BitFoundation +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import struct Foundation.Data +import class CoreFoundation.CFString +import typealias Darwin.OSStatus + +public protocol KeychainManagerProtocol { + func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool + 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 + + // BCH-01-009: Methods with proper error classification + /// Get identity key with detailed result for error handling + func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult + /// Save identity key with detailed result for error handling + func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult + + // 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) +} + +// MARK: - Keychain Error Types +// BCH-01-009: Proper error classification to distinguish expected states from critical failures + +/// Result of a keychain read operation with proper error classification +public enum KeychainReadResult { + case success(Data) + case itemNotFound // Expected: key doesn't exist yet + case accessDenied // Critical: app lacks keychain access + case deviceLocked // Recoverable: device is locked + case authenticationFailed // Recoverable: biometric/passcode failed + case otherError(OSStatus) // Unexpected error + + public var isRecoverableError: Bool { + switch self { + case .deviceLocked, .authenticationFailed: + return true + default: + return false + } + } +} + +/// Result of a keychain save operation with proper error classification +public enum KeychainSaveResult { + case success + case duplicateItem // Can retry with update + case accessDenied // Critical: app lacks keychain access + case deviceLocked // Recoverable: device is locked + case storageFull // Critical: no space available + case otherError(OSStatus) + + public var isRecoverableError: Bool { + switch self { + case .duplicateItem, .deviceLocked: + return true + default: + return false + } + } +} diff --git a/bitchat/Models/MessagePadding.swift b/localPackages/BitFoundation/Sources/BitFoundation/MessagePadding.swift similarity index 98% rename from bitchat/Models/MessagePadding.swift rename to localPackages/BitFoundation/Sources/BitFoundation/MessagePadding.swift index c9acd795..0d29c8bc 100644 --- a/bitchat/Models/MessagePadding.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/MessagePadding.swift @@ -6,7 +6,7 @@ // For more information, see // -import Foundation +import struct Foundation.Data /// Provides privacy-preserving message padding to obscure actual content length. /// Uses PKCS#7-style padding with random bytes to prevent traffic analysis. diff --git a/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift b/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift new file mode 100644 index 00000000..5c54781f --- /dev/null +++ b/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift @@ -0,0 +1,39 @@ +// +// MessageType.swift +// BitFoundation +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +/// Simplified BitChat protocol message types. +/// Reduced from 24 types to just 6 essential ones. +/// All private communication metadata (receipts, status) is embedded in noiseEncrypted payloads. +public enum MessageType: UInt8 { + // Public messages (unencrypted) + case announce = 0x01 // "I'm here" with nickname + case message = 0x02 // Public chat message + case leave = 0x03 // "I'm leaving" + case requestSync = 0x21 // GCS filter-based sync request (local-only) + + // Noise encryption + case noiseHandshake = 0x10 // Handshake (init or response determined by payload) + case noiseEncrypted = 0x11 // All encrypted payloads (messages, receipts, etc.) + + // Fragmentation (simplified) + case fragment = 0x20 // Single fragment type for large messages + case fileTransfer = 0x22 // Binary file/audio/image payloads + + public var description: String { + switch self { + case .announce: return "announce" + case .message: return "message" + case .leave: return "leave" + case .requestSync: return "requestSync" + case .noiseHandshake: return "noiseHandshake" + case .noiseEncrypted: return "noiseEncrypted" + case .fragment: return "fragment" + case .fileTransfer: return "fileTransfer" + } + } +} diff --git a/bitchatTests/Protocol/BinaryProtocolPaddingTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/BinaryProtocolPaddingTests.swift similarity index 97% rename from bitchatTests/Protocol/BinaryProtocolPaddingTests.swift rename to localPackages/BitFoundation/Tests/BitFoundationTests/BinaryProtocolPaddingTests.swift index a5e3f44f..7e87fae5 100644 --- a/bitchatTests/Protocol/BinaryProtocolPaddingTests.swift +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/BinaryProtocolPaddingTests.swift @@ -6,7 +6,7 @@ // import Testing -@testable import bitchat +@testable import BitFoundation struct BinaryProtocolPaddingTests { @Test func padded_vs_unpadded_length() throws { diff --git a/bitchatTests/Protocol/BinaryProtocolTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/BinaryProtocolTests.swift similarity index 99% rename from bitchatTests/Protocol/BinaryProtocolTests.swift rename to localPackages/BitFoundation/Tests/BitFoundationTests/BinaryProtocolTests.swift index 61a5c906..4016080f 100644 --- a/bitchatTests/Protocol/BinaryProtocolTests.swift +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/BinaryProtocolTests.swift @@ -8,8 +8,7 @@ import Testing import Foundation -import BitFoundation -@testable import bitchat +@testable import BitFoundation struct BinaryProtocolTests { @@ -806,3 +805,13 @@ struct BinaryProtocolTests { // We don't assert the result, just that no crash happens } } + +private extension Data { + func trimmingNullBytes() -> Data { + // Find the first null byte + if let nullIndex = self.firstIndex(of: 0) { + return self.prefix(nullIndex) + } + return self + } +} diff --git a/bitchatTests/KeychainErrorHandlingTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/KeychainErrorHandlingTests.swift similarity index 67% rename from bitchatTests/KeychainErrorHandlingTests.swift rename to localPackages/BitFoundation/Tests/BitFoundationTests/KeychainErrorHandlingTests.swift index ad48c1e3..5d5f94f6 100644 --- a/bitchatTests/KeychainErrorHandlingTests.swift +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/KeychainErrorHandlingTests.swift @@ -9,7 +9,7 @@ import Testing import Foundation -@testable import bitchat +import BitFoundation struct KeychainErrorHandlingTests { @@ -145,67 +145,6 @@ struct KeychainErrorHandlingTests { throw KeychainTestError("Expected save success, got \(saveResult)") } } - - // MARK: - NoiseEncryptionService Integration Tests - - @Test func noiseEncryptionService_generatesNewIdentityWhenMissing() throws { - let keychain = MockKeychain() - - // Create service with empty keychain - should generate new identity - let service = NoiseEncryptionService(keychain: keychain) - - // Should have generated and saved keys - #expect(service.getStaticPublicKeyData().count == 32) - #expect(service.getSigningPublicKeyData().count == 32) - - // Keys should be persisted - let noiseKeyResult = keychain.getIdentityKeyWithResult(forKey: "noiseStaticKey") - switch noiseKeyResult { - case .success: - // Expected - key was saved - break - default: - throw KeychainTestError("Expected noise key to be saved") - } - } - - @Test func noiseEncryptionService_loadsExistingIdentity() throws { - let keychain = MockKeychain() - - // Create first service to generate identity - let service1 = NoiseEncryptionService(keychain: keychain) - let originalPublicKey = service1.getStaticPublicKeyData() - let originalSigningKey = service1.getSigningPublicKeyData() - - // Create second service - should load same identity - let service2 = NoiseEncryptionService(keychain: keychain) - - #expect(service2.getStaticPublicKeyData() == originalPublicKey) - #expect(service2.getSigningPublicKeyData() == originalSigningKey) - } - - @Test func noiseEncryptionService_handlesAccessDeniedGracefully() throws { - let keychain = MockKeychain() - keychain.simulatedReadError = .accessDenied - - // Service should still initialize with ephemeral key - let service = NoiseEncryptionService(keychain: keychain) - - // Should have an identity (ephemeral) - #expect(service.getStaticPublicKeyData().count == 32) - #expect(service.getSigningPublicKeyData().count == 32) - } - - @Test func noiseEncryptionService_handlesDeviceLockedGracefully() throws { - let keychain = MockKeychain() - keychain.simulatedReadError = .deviceLocked - - // Service should still initialize with ephemeral key - let service = NoiseEncryptionService(keychain: keychain) - - // Should have an identity (ephemeral) - #expect(service.getStaticPublicKeyData().count == 32) - } } // Helper error type for tests diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/MockKeychain.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/MockKeychain.swift new file mode 100644 index 00000000..5d4071ec --- /dev/null +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/MockKeychain.swift @@ -0,0 +1,201 @@ +// +// MockKeychain.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import BitFoundation + +// TODO: Create a module for test helpers +final class MockKeychain: KeychainManagerProtocol { + private var storage: [String: Data] = [:] + private var serviceStorage: [String: [String: Data]] = [:] + + // BCH-01-009: Configurable error simulation for testing + var simulatedReadError: KeychainReadResult? + var simulatedSaveError: KeychainSaveResult? + + 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 + } + + // BCH-01-009: New methods with proper error classification + func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult { + if let simulated = simulatedReadError { + return simulated + } + if let data = storage[key] { + return .success(data) + } + return .itemNotFound + } + + func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult { + if let simulated = simulatedSaveError { + return simulated + } + storage[key] = keyData + return .success + } + + // 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) + } +} + +/// Typealias for backwards compatibility with tests using MockKeychainHelper +typealias MockKeychainHelper = MockKeychain + +/// Mock keychain that tracks secureClear calls for testing DH secret clearing +final class TrackingMockKeychain: KeychainManagerProtocol { + private var storage: [String: Data] = [:] + private var serviceStorage: [String: [String: Data]] = [:] + + /// Thread-safe counter for secureClear calls + private let lock = NSLock() + private var _secureClearDataCallCount = 0 + private var _secureClearStringCallCount = 0 + + // BCH-01-009: Configurable error simulation for testing + var simulatedReadError: KeychainReadResult? + var simulatedSaveError: KeychainSaveResult? + + var secureClearDataCallCount: Int { + lock.lock() + defer { lock.unlock() } + return _secureClearDataCallCount + } + + var secureClearStringCallCount: Int { + lock.lock() + defer { lock.unlock() } + return _secureClearStringCallCount + } + + var totalSecureClearCallCount: Int { + return secureClearDataCallCount + secureClearStringCallCount + } + + func resetCounts() { + lock.lock() + defer { lock.unlock() } + _secureClearDataCallCount = 0 + _secureClearStringCallCount = 0 + } + + 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) { + lock.lock() + _secureClearDataCallCount += 1 + lock.unlock() + data = Data() + } + + func secureClear(_ string: inout String) { + lock.lock() + _secureClearStringCallCount += 1 + lock.unlock() + string = "" + } + + func verifyIdentityKeyExists() -> Bool { + storage["identity_noiseStaticKey"] != nil + } + + // BCH-01-009: New methods with proper error classification + func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult { + if let simulated = simulatedReadError { + return simulated + } + if let data = storage[key] { + return .success(data) + } + return .itemNotFound + } + + func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult { + if let simulated = simulatedSaveError { + return simulated + } + storage[key] = keyData + return .success + } + + 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/localPackages/BitFoundation/Tests/BitFoundationTests/TestConstants.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/TestConstants.swift new file mode 100644 index 00000000..f2f7b35b --- /dev/null +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/TestConstants.swift @@ -0,0 +1,28 @@ +// +// TestConstants.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +// TODO: Create a module for test helpers +struct TestConstants { + static let defaultTimeout: TimeInterval = 5.0 + static let shortTimeout: TimeInterval = 1.0 + static let longTimeout: TimeInterval = 10.0 + + static let testNickname1 = "Alice" + static let testNickname2 = "Bob" + static let testNickname3 = "Charlie" + static let testNickname4 = "David" + + static let testMessage1 = "Hello, World!" + static let testMessage2 = "How are you?" + static let testMessage3 = "This is a test message" + static let testLongMessage = String(repeating: "This is a long message. ", count: 100) + + static let testSignature = Data(repeating: 0xAB, count: 64) +} diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/TestHelpers.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/TestHelpers.swift new file mode 100644 index 00000000..5c8ca7c0 --- /dev/null +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/TestHelpers.swift @@ -0,0 +1,143 @@ +// +// TestHelpers.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import CryptoKit +@testable import BitFoundation + +// TODO: Create a module for test helpers +final class TestHelpers { + + // MARK: - Key Generation + + static func generateTestKeyPair() -> (privateKey: Curve25519.KeyAgreement.PrivateKey, publicKey: Curve25519.KeyAgreement.PublicKey) { + let privateKey = Curve25519.KeyAgreement.PrivateKey() + let publicKey = privateKey.publicKey + return (privateKey, publicKey) + } + + static func generateTestIdentity(peerID: String, nickname: String) -> (peerID: String, nickname: String, privateKey: Curve25519.KeyAgreement.PrivateKey, publicKey: Curve25519.KeyAgreement.PublicKey) { + let (privateKey, publicKey) = generateTestKeyPair() + return (peerID: peerID, nickname: nickname, privateKey: privateKey, publicKey: publicKey) + } + + // MARK: - Message Creation + + static func createTestMessage( + content: String = TestConstants.testMessage1, + sender: String = TestConstants.testNickname1, + senderPeerID: PeerID = PeerID(str: UUID().uuidString), + isPrivate: Bool = false, + recipientNickname: String? = nil, + mentions: [String]? = nil + ) -> BitchatMessage { + return BitchatMessage( + id: UUID().uuidString, + sender: sender, + content: content, + timestamp: Date(), + isRelay: false, + originalSender: nil, + isPrivate: isPrivate, + recipientNickname: recipientNickname, + senderPeerID: senderPeerID, + mentions: mentions + ) + } + + static func createTestPacket( + type: UInt8 = 0x01, + senderID: PeerID = PeerID(str: UUID().uuidString), + recipientID: PeerID? = nil, + payload: Data = "test payload".data(using: .utf8)!, + signature: Data? = nil, + ttl: UInt8 = 3 + ) -> BitchatPacket { + return BitchatPacket( + type: type, + senderID: senderID.id.data(using: .utf8)!, + recipientID: recipientID?.id.data(using: .utf8), + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: signature, + ttl: ttl + ) + } + + // MARK: - Data Generation + + static func generateRandomData(length: Int) -> Data { + var data = Data(count: length) + _ = data.withUnsafeMutableBytes { bytes in + SecRandomCopyBytes(kSecRandomDefault, length, bytes.baseAddress!) + } + return data + } + + static func generateTestPeerID() -> String { + return "PEER" + UUID().uuidString.prefix(8) + } + + // MARK: - Async Helpers + + static func waitFor(_ condition: @escaping () -> Bool, timeout: TimeInterval = TestConstants.defaultTimeout) async throws { + let start = Date() + while !condition() { + if Date().timeIntervalSince(start) > timeout { + throw TestError.timeout + } + try await sleep(0.01) + } + } + + @MainActor + static func waitUntil( + _ condition: @escaping () -> Bool, + timeout: TimeInterval = TestConstants.defaultTimeout, + pollInterval: TimeInterval = 0.01 + ) async -> Bool { + let start = Date() + while !condition() { + if Date().timeIntervalSince(start) > timeout { + return condition() + } + try? await sleep(pollInterval) + } + return true + } + + static func expectAsync( + timeout: TimeInterval = TestConstants.defaultTimeout, + operation: @escaping () async throws -> T + ) async throws -> T { + return try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { + return try await operation() + } + + group.addTask { + try await sleep(1) + throw TestError.timeout + } + + let result = try await group.next()! + group.cancelAll() + return result + } + } +} + +enum TestError: Error { + case timeout + case unexpectedValue + case testFailure(String) +} + +func sleep(_ seconds: TimeInterval) async throws { + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) +}