From 6a6504c6f24739bb738bcb896f22965570b74423 Mon Sep 17 00:00:00 2001 From: Islam <2553451+qalandarov@users.noreply.github.com> Date: Mon, 15 Sep 2025 14:21:03 +0100 Subject: [PATCH] Refactor: Extract types from BitchatProtocol (#611) * Extract BitchatMessage into a separate file * Convert `fromBinaryPayload` to `convenience init?` * Extract message dedup into an extension * Remove dead `formatMessageContent` * Minor refactor of timestamp and username formatting * Remove dead `getSenderColor` * Extract MessagePadding into a separate file * Extract BitchatPacket into a separate file * Extract ReadReceipt into a separate file * Extract NoisePayload into a separate file * Remove unnecessary import --- bitchat/Models/BitchatPacket.swift | 91 ++++++++ bitchat/Models/MessagePadding.swift | 61 ++++++ bitchat/Models/NoisePayload.swift | 41 ++++ bitchat/Models/ReadReceipt.swift | 95 +++++++++ bitchat/Protocols/BitchatProtocol.swift | 270 ------------------------ 5 files changed, 288 insertions(+), 270 deletions(-) create mode 100644 bitchat/Models/BitchatPacket.swift create mode 100644 bitchat/Models/MessagePadding.swift create mode 100644 bitchat/Models/NoisePayload.swift create mode 100644 bitchat/Models/ReadReceipt.swift diff --git a/bitchat/Models/BitchatPacket.swift b/bitchat/Models/BitchatPacket.swift new file mode 100644 index 00000000..0fc2a05a --- /dev/null +++ b/bitchat/Models/BitchatPacket.swift @@ -0,0 +1,91 @@ +// +// BitchatPacket.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +/// 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 { + let version: UInt8 + let type: UInt8 + let senderID: Data + let recipientID: Data? + let timestamp: UInt64 + let payload: Data + var signature: Data? + var ttl: UInt8 + + init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8) { + self.version = 1 + self.type = type + self.senderID = senderID + self.recipientID = recipientID + self.timestamp = timestamp + self.payload = payload + self.signature = signature + self.ttl = ttl + } + + // Convenience initializer for new binary format + init(type: UInt8, ttl: UInt8, senderID: String, payload: Data) { + self.version = 1 + self.type = type + // Convert hex string peer ID to binary data (8 bytes) + var senderData = Data() + var tempID = senderID + while tempID.count >= 2 { + let hexByte = String(tempID.prefix(2)) + if let byte = UInt8(hexByte, radix: 16) { + senderData.append(byte) + } + tempID = String(tempID.dropFirst(2)) + } + self.senderID = senderData + self.recipientID = nil + self.timestamp = UInt64(Date().timeIntervalSince1970 * 1000) // milliseconds + self.payload = payload + self.signature = nil + self.ttl = ttl + } + + var data: Data? { + BinaryProtocol.encode(self) + } + + func toBinaryData(padding: Bool = true) -> Data? { + BinaryProtocol.encode(self, padding: padding) + } + + // Backward-compatible helper (defaults to padded encoding) + 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? { + // Create a copy without signature and with fixed TTL for signing + // TTL must be excluded because it changes during relay + let unsignedPacket = BitchatPacket( + type: type, + senderID: senderID, + recipientID: recipientID, + timestamp: timestamp, + payload: payload, + signature: nil, // Remove signature for signing + ttl: 0 // Use fixed TTL=0 for signing to ensure relay compatibility + ) + return BinaryProtocol.encode(unsignedPacket) + } + + static func from(_ data: Data) -> BitchatPacket? { + BinaryProtocol.decode(data) + } +} diff --git a/bitchat/Models/MessagePadding.swift b/bitchat/Models/MessagePadding.swift new file mode 100644 index 00000000..c9acd795 --- /dev/null +++ b/bitchat/Models/MessagePadding.swift @@ -0,0 +1,61 @@ +// +// MessagePadding.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +/// Provides privacy-preserving message padding to obscure actual content length. +/// Uses PKCS#7-style padding with random bytes to prevent traffic analysis. +struct MessagePadding { + // Standard block sizes for padding + static let blockSizes = [256, 512, 1024, 2048] + + // Add PKCS#7-style padding to reach target size + static func pad(_ data: Data, toSize targetSize: Int) -> Data { + guard data.count < targetSize else { return data } + + let paddingNeeded = targetSize - data.count + // Constrain to 255 to fit a single-byte pad length marker + guard paddingNeeded > 0 && paddingNeeded <= 255 else { return data } + + var padded = data + // PKCS#7: All pad bytes are equal to the pad length + padded.append(contentsOf: Array(repeating: UInt8(paddingNeeded), count: paddingNeeded)) + return padded + } + + // Remove padding from data + static func unpad(_ data: Data) -> Data { + guard !data.isEmpty else { return data } + let last = data.last! + let paddingLength = Int(last) + // Must have at least 1 pad byte and not exceed data length + guard paddingLength > 0 && paddingLength <= data.count else { return data } + // Verify PKCS#7: all last N bytes equal to pad length + let start = data.count - paddingLength + let tail = data[start...] + for b in tail { if b != last { return data } } + return Data(data[.. Int { + // Account for encryption overhead (~16 bytes for AES-GCM tag) + let totalSize = dataSize + 16 + + // Find smallest block that fits + for blockSize in blockSizes { + if totalSize <= blockSize { + return blockSize + } + } + + // For very large messages, just use the original size + // (will be fragmented anyway) + return dataSize + } +} diff --git a/bitchat/Models/NoisePayload.swift b/bitchat/Models/NoisePayload.swift new file mode 100644 index 00000000..9d88a991 --- /dev/null +++ b/bitchat/Models/NoisePayload.swift @@ -0,0 +1,41 @@ +// +// NoisePayload.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +/// Helper to create typed Noise payloads +struct NoisePayload { + let type: NoisePayloadType + let data: Data + + /// Encode payload with type prefix + func encode() -> Data { + var encoded = Data() + encoded.append(type.rawValue) + encoded.append(data) + return encoded + } + + /// Decode payload from data + static func decode(_ data: Data) -> NoisePayload? { + // Ensure we have at least 1 byte for the type + guard !data.isEmpty else { + return nil + } + + // Safely get the first byte + let firstByte = data[data.startIndex] + guard let type = NoisePayloadType(rawValue: firstByte) else { + return nil + } + + // Create a proper Data copy (not a subsequence) for thread safety + let payloadData = data.count > 1 ? Data(data.dropFirst()) : Data() + return NoisePayload(type: type, data: payloadData) + } +} diff --git a/bitchat/Models/ReadReceipt.swift b/bitchat/Models/ReadReceipt.swift new file mode 100644 index 00000000..2e261dae --- /dev/null +++ b/bitchat/Models/ReadReceipt.swift @@ -0,0 +1,95 @@ +// +// ReadReceipt.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +struct ReadReceipt: Codable { + let originalMessageID: String + let receiptID: String + var readerID: String // Who read it + let readerNickname: String + let timestamp: Date + + init(originalMessageID: String, readerID: String, readerNickname: String) { + self.originalMessageID = originalMessageID + self.receiptID = UUID().uuidString + self.readerID = readerID + self.readerNickname = readerNickname + self.timestamp = Date() + } + + // For binary decoding + private init(originalMessageID: String, receiptID: String, readerID: String, readerNickname: String, timestamp: Date) { + self.originalMessageID = originalMessageID + self.receiptID = receiptID + self.readerID = readerID + self.readerNickname = readerNickname + self.timestamp = timestamp + } + + func encode() -> Data? { + try? JSONEncoder().encode(self) + } + + static func decode(from data: Data) -> ReadReceipt? { + try? JSONDecoder().decode(ReadReceipt.self, from: data) + } + + // MARK: - Binary Encoding + + func toBinaryData() -> Data { + var data = Data() + data.appendUUID(originalMessageID) + data.appendUUID(receiptID) + // ReaderID as 8-byte hex string + var readerData = Data() + var tempID = readerID + while tempID.count >= 2 && readerData.count < 8 { + let hexByte = String(tempID.prefix(2)) + if let byte = UInt8(hexByte, radix: 16) { + readerData.append(byte) + } + tempID = String(tempID.dropFirst(2)) + } + while readerData.count < 8 { + readerData.append(0) + } + data.append(readerData) + data.appendDate(timestamp) + data.appendString(readerNickname) + return data + } + + static func fromBinaryData(_ data: Data) -> ReadReceipt? { + // Create defensive copy + let dataCopy = Data(data) + + // Minimum size: 2 UUIDs (32) + readerID (8) + timestamp (8) + min nickname + guard dataCopy.count >= 49 else { return nil } + + var offset = 0 + + guard let originalMessageID = dataCopy.readUUID(at: &offset), + let receiptID = dataCopy.readUUID(at: &offset) else { return nil } + + guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil } + let readerID = readerIDData.hexEncodedString() + guard InputValidator.validatePeerID(readerID) else { return nil } + + guard let timestamp = dataCopy.readDate(at: &offset), + InputValidator.validateTimestamp(timestamp), + let readerNicknameRaw = dataCopy.readString(at: &offset), + let readerNickname = InputValidator.validateNickname(readerNicknameRaw) else { return nil } + + return ReadReceipt(originalMessageID: originalMessageID, + receiptID: receiptID, + readerID: readerID, + readerNickname: readerNickname, + timestamp: timestamp) + } +} diff --git a/bitchat/Protocols/BitchatProtocol.swift b/bitchat/Protocols/BitchatProtocol.swift index b9fff31f..9090e56c 100644 --- a/bitchat/Protocols/BitchatProtocol.swift +++ b/bitchat/Protocols/BitchatProtocol.swift @@ -59,61 +59,6 @@ /// import Foundation -import CryptoKit - -// MARK: - Message Padding - -/// Provides privacy-preserving message padding to obscure actual content length. -/// Uses PKCS#7-style padding with random bytes to prevent traffic analysis. -struct MessagePadding { - // Standard block sizes for padding - static let blockSizes = [256, 512, 1024, 2048] - - // Add PKCS#7-style padding to reach target size - static func pad(_ data: Data, toSize targetSize: Int) -> Data { - guard data.count < targetSize else { return data } - - let paddingNeeded = targetSize - data.count - // Constrain to 255 to fit a single-byte pad length marker - guard paddingNeeded > 0 && paddingNeeded <= 255 else { return data } - - var padded = data - // PKCS#7: All pad bytes are equal to the pad length - padded.append(contentsOf: Array(repeating: UInt8(paddingNeeded), count: paddingNeeded)) - return padded - } - - // Remove padding from data - static func unpad(_ data: Data) -> Data { - guard !data.isEmpty else { return data } - let last = data.last! - let paddingLength = Int(last) - // Must have at least 1 pad byte and not exceed data length - guard paddingLength > 0 && paddingLength <= data.count else { return data } - // Verify PKCS#7: all last N bytes equal to pad length - let start = data.count - paddingLength - let tail = data[start...] - for b in tail { if b != last { return data } } - return Data(data[.. Int { - // Account for encryption overhead (~16 bytes for AES-GCM tag) - let totalSize = dataSize + 16 - - // Find smallest block that fits - for blockSize in blockSizes { - if totalSize <= blockSize { - return blockSize - } - } - - // For very large messages, just use the original size - // (will be fragmented anyway) - return dataSize - } -} // MARK: - Message Types @@ -183,187 +128,6 @@ enum LazyHandshakeState { case failed(Error) // Handshake failed } -// - -// MARK: - Core Protocol Structures - -/// 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 { - let version: UInt8 - let type: UInt8 - let senderID: Data - let recipientID: Data? - let timestamp: UInt64 - let payload: Data - var signature: Data? - var ttl: UInt8 - - init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8) { - self.version = 1 - self.type = type - self.senderID = senderID - self.recipientID = recipientID - self.timestamp = timestamp - self.payload = payload - self.signature = signature - self.ttl = ttl - } - - // Convenience initializer for new binary format - init(type: UInt8, ttl: UInt8, senderID: String, payload: Data) { - self.version = 1 - self.type = type - // Convert hex string peer ID to binary data (8 bytes) - var senderData = Data() - var tempID = senderID - while tempID.count >= 2 { - let hexByte = String(tempID.prefix(2)) - if let byte = UInt8(hexByte, radix: 16) { - senderData.append(byte) - } - tempID = String(tempID.dropFirst(2)) - } - self.senderID = senderData - self.recipientID = nil - self.timestamp = UInt64(Date().timeIntervalSince1970 * 1000) // milliseconds - self.payload = payload - self.signature = nil - self.ttl = ttl - } - - var data: Data? { - BinaryProtocol.encode(self) - } - - func toBinaryData(padding: Bool = true) -> Data? { - BinaryProtocol.encode(self, padding: padding) - } - - // Backward-compatible helper (defaults to padded encoding) - 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? { - // Create a copy without signature and with fixed TTL for signing - // TTL must be excluded because it changes during relay - let unsignedPacket = BitchatPacket( - type: type, - senderID: senderID, - recipientID: recipientID, - timestamp: timestamp, - payload: payload, - signature: nil, // Remove signature for signing - ttl: 0 // Use fixed TTL=0 for signing to ensure relay compatibility - ) - return BinaryProtocol.encode(unsignedPacket) - } - - static func from(_ data: Data) -> BitchatPacket? { - BinaryProtocol.decode(data) - } -} - -// - -// MARK: - Read Receipts - -// Read receipt structure -struct ReadReceipt: Codable { - let originalMessageID: String - let receiptID: String - var readerID: String // Who read it - let readerNickname: String - let timestamp: Date - - init(originalMessageID: String, readerID: String, readerNickname: String) { - self.originalMessageID = originalMessageID - self.receiptID = UUID().uuidString - self.readerID = readerID - self.readerNickname = readerNickname - self.timestamp = Date() - } - - // For binary decoding - private init(originalMessageID: String, receiptID: String, readerID: String, readerNickname: String, timestamp: Date) { - self.originalMessageID = originalMessageID - self.receiptID = receiptID - self.readerID = readerID - self.readerNickname = readerNickname - self.timestamp = timestamp - } - - func encode() -> Data? { - try? JSONEncoder().encode(self) - } - - static func decode(from data: Data) -> ReadReceipt? { - try? JSONDecoder().decode(ReadReceipt.self, from: data) - } - - // MARK: - Binary Encoding - - func toBinaryData() -> Data { - var data = Data() - data.appendUUID(originalMessageID) - data.appendUUID(receiptID) - // ReaderID as 8-byte hex string - var readerData = Data() - var tempID = readerID - while tempID.count >= 2 && readerData.count < 8 { - let hexByte = String(tempID.prefix(2)) - if let byte = UInt8(hexByte, radix: 16) { - readerData.append(byte) - } - tempID = String(tempID.dropFirst(2)) - } - while readerData.count < 8 { - readerData.append(0) - } - data.append(readerData) - data.appendDate(timestamp) - data.appendString(readerNickname) - return data - } - - static func fromBinaryData(_ data: Data) -> ReadReceipt? { - // Create defensive copy - let dataCopy = Data(data) - - // Minimum size: 2 UUIDs (32) + readerID (8) + timestamp (8) + min nickname - guard dataCopy.count >= 49 else { return nil } - - var offset = 0 - - guard let originalMessageID = dataCopy.readUUID(at: &offset), - let receiptID = dataCopy.readUUID(at: &offset) else { return nil } - - guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil } - let readerID = readerIDData.hexEncodedString() - guard InputValidator.validatePeerID(readerID) else { return nil } - - guard let timestamp = dataCopy.readDate(at: &offset), - InputValidator.validateTimestamp(timestamp), - let readerNicknameRaw = dataCopy.readString(at: &offset), - let readerNickname = InputValidator.validateNickname(readerNicknameRaw) else { return nil } - - return ReadReceipt(originalMessageID: originalMessageID, - receiptID: receiptID, - readerID: readerID, - readerNickname: readerNickname, - timestamp: timestamp) - } -} - - -// - - // MARK: - Delivery Status // Delivery status for messages @@ -429,37 +193,3 @@ extension BitchatDelegate { // Default empty implementation } } - -// MARK: - Noise Payload Helpers - -/// Helper to create typed Noise payloads -struct NoisePayload { - let type: NoisePayloadType - let data: Data - - /// Encode payload with type prefix - func encode() -> Data { - var encoded = Data() - encoded.append(type.rawValue) - encoded.append(data) - return encoded - } - - /// Decode payload from data - static func decode(_ data: Data) -> NoisePayload? { - // Ensure we have at least 1 byte for the type - guard !data.isEmpty else { - return nil - } - - // Safely get the first byte - let firstByte = data[data.startIndex] - guard let type = NoisePayloadType(rawValue: firstByte) else { - return nil - } - - // Create a proper Data copy (not a subsequence) for thread safety - let payloadData = data.count > 1 ? Data(data.dropFirst()) : Data() - return NoisePayload(type: type, data: payloadData) - } -}