Move additional files/tests to BitFoundation (#1102)

This commit is contained in:
Islam
2026-04-15 12:26:48 -05:00
committed by GitHub
parent 4cfcefcda6
commit c60eff2c11
46 changed files with 741 additions and 293 deletions
@@ -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(
@@ -0,0 +1,195 @@
//
// BinaryEncodingUtils.swift
// bitchat
//
// Binary encoding utilities for efficient protocol messages
//
import struct Foundation.Data
import struct Foundation.Date
// MARK: - Binary Encoding Utilities
extension Data {
// MARK: Writing
@inlinable public mutating func appendUInt8(_ value: UInt8) {
self.append(value)
}
@inlinable mutating func appendUInt16(_ value: UInt16) {
self.append(UInt8((value >> 8) & 0xFF))
self.append(UInt8(value & 0xFF))
}
@inlinable mutating func appendUInt32(_ value: UInt32) {
self.append(UInt8((value >> 24) & 0xFF))
self.append(UInt8((value >> 16) & 0xFF))
self.append(UInt8((value >> 8) & 0xFF))
self.append(UInt8(value & 0xFF))
}
@inlinable mutating func appendUInt64(_ value: UInt64) {
for i in (0..<8).reversed() {
self.append(UInt8((value >> (i * 8)) & 0xFF))
}
}
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)
if maxLength <= 255 {
self.append(UInt8(length))
} else {
self.appendUInt16(UInt16(length))
}
self.append(data.prefix(length))
}
public mutating func appendData(_ data: Data, maxLength: Int = 65535) {
let length = Swift.min(data.count, maxLength)
if maxLength <= 255 {
self.append(UInt8(length))
} else {
self.appendUInt16(UInt16(length))
}
self.append(data.prefix(length))
}
public mutating func appendDate(_ date: Date) {
let timestamp = UInt64(date.timeIntervalSince1970 * 1000) // milliseconds
self.appendUInt64(timestamp)
}
public mutating func appendUUID(_ uuid: String) {
// Convert UUID string to 16 bytes
var uuidData = Data(count: 16)
let cleanUUID = uuid.replacingOccurrences(of: "-", with: "")
var index = cleanUUID.startIndex
for i in 0..<16 {
guard index < cleanUUID.endIndex else { break }
let nextIndex = cleanUUID.index(index, offsetBy: 2)
if let byte = UInt8(String(cleanUUID[index..<nextIndex]), radix: 16) {
uuidData[i] = byte
}
index = nextIndex
}
self.append(uuidData)
}
// MARK: Reading
@inlinable public func readUInt8(at offset: inout Int) -> UInt8? {
guard offset >= 0 && offset < self.count else { return nil }
let value = self[offset]
offset += 1
return value
}
@inlinable func readUInt16(at offset: inout Int) -> UInt16? {
guard offset + 2 <= self.count else { return nil }
let value = UInt16(self[offset]) << 8 | UInt16(self[offset + 1])
offset += 2
return value
}
@inlinable func readUInt32(at offset: inout Int) -> UInt32? {
guard offset + 4 <= self.count else { return nil }
let value = UInt32(self[offset]) << 24 |
UInt32(self[offset + 1]) << 16 |
UInt32(self[offset + 2]) << 8 |
UInt32(self[offset + 3])
offset += 4
return value
}
@inlinable func readUInt64(at offset: inout Int) -> UInt64? {
guard offset + 8 <= self.count else { return nil }
var value: UInt64 = 0
for i in 0..<8 {
value = (value << 8) | UInt64(self[offset + i])
}
offset += 8
return value
}
public func readString(at offset: inout Int, maxLength: Int = 255) -> String? {
let length: Int
if maxLength <= 255 {
guard let len = readUInt8(at: &offset) else { return nil }
length = Int(len)
} else {
guard let len = readUInt16(at: &offset) else { return nil }
length = Int(len)
}
guard offset + length <= self.count else { return nil }
let stringData = self[offset..<offset + length]
offset += length
return String(data: stringData, encoding: .utf8)
}
public func readData(at offset: inout Int, maxLength: Int = 65535) -> Data? {
let length: Int
if maxLength <= 255 {
guard let len = readUInt8(at: &offset) else { return nil }
length = Int(len)
} else {
guard let len = readUInt16(at: &offset) else { return nil }
length = Int(len)
}
guard offset + length <= self.count else { return nil }
let data = self[offset..<offset + length]
offset += length
return data
}
public func readDate(at offset: inout Int) -> Date? {
guard let timestamp = readUInt64(at: &offset) else { return nil }
return Date(timeIntervalSince1970: Double(timestamp) / 1000.0)
}
public func readUUID(at offset: inout Int) -> String? {
guard offset + 16 <= self.count else { return nil }
let uuidData = self[offset..<offset + 16]
offset += 16
// Convert 16 bytes to UUID string format
let uuid = uuidData.hexEncodedString()
// Insert hyphens at proper positions: 8-4-4-4-12
var result = ""
for (index, char) in uuid.enumerated() {
if index == 8 || index == 12 || index == 16 || index == 20 {
result += "-"
}
result.append(char)
}
return result.uppercased()
}
public func readFixedBytes(at offset: inout Int, count: Int) -> Data? {
guard offset + count <= self.count else { return nil }
let data = self[offset..<offset + count]
offset += count
return data
}
}
@@ -0,0 +1,413 @@
//
// BinaryProtocol.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
///
/// # BinaryProtocol
///
/// Low-level binary encoding and decoding for BitChat protocol messages.
/// Optimized for Bluetooth LE's limited bandwidth and MTU constraints.
///
/// ## Overview
/// BinaryProtocol implements an efficient binary wire format that minimizes
/// overhead while maintaining extensibility. It handles:
/// - Compact binary encoding with fixed headers
/// - Optional field support via flags
/// - Automatic compression for large payloads
/// - Endianness handling for cross-platform compatibility
///
/// ## Wire Format
/// ```
/// Header (Fixed 14 bytes for v1, 16 bytes for v2):
/// +--------+------+-----+-----------+-------+------------------+
/// |Version | Type | TTL | Timestamp | Flags | PayloadLength |
/// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 or 4 bytes |
/// +--------+------+-----+-----------+-------+------------------+
///
/// Variable sections:
/// +----------+-------------+---------+------------+
/// | SenderID | RecipientID | Payload | Signature |
/// | 8 bytes | 8 bytes* | Variable| 64 bytes* |
/// +----------+-------------+---------+------------+
/// * Optional fields based on flags
/// ```
///
/// ## Design Rationale
/// The protocol is designed for:
/// - **Efficiency**: Minimal overhead for small messages
/// - **Flexibility**: Optional fields via flag bits
/// - **Compatibility**: Network byte order (big-endian)
/// - **Performance**: Zero-copy where possible
///
/// ## Compression Strategy
/// - Automatic compression for payloads > 256 bytes
/// - zlib compression for broad compatibility on Apple platforms
/// - Original size stored for decompression
/// - Flag bit indicates compressed payload
///
/// ## Flag Bits
/// - Bit 0: Has recipient ID (directed message)
/// - Bit 1: Has signature (authenticated message)
/// - Bit 2: Is compressed (zlib compression applied)
/// - Bits 3-7: Reserved for future use
///
/// ## Size Constraints
/// - Maximum packet size: 65,535 bytes (16-bit length field)
/// - Typical packet size: < 512 bytes (BLE MTU)
/// - Minimum packet size: 21 bytes (header + sender ID)
///
/// ## Encoding Process
/// 1. Construct header with fixed fields
/// 2. Set appropriate flags
/// 3. Compress payload if beneficial
/// 4. Append variable-length fields
/// 5. Calculate and append signature if needed
///
/// ## Decoding Process
/// 1. Validate minimum packet size
/// 2. Parse fixed header
/// 3. Extract flags and determine field presence
/// 4. Parse variable fields based on flags
/// 5. Decompress payload if compressed
/// 6. Verify signature if present
///
/// ## Error Handling
/// - Graceful handling of malformed packets
/// - Clear error messages for debugging
/// - No crashes on invalid input
/// - Logging of protocol violations
///
/// ## Performance Notes
/// - Allocation-free for small messages
/// - Streaming support for large payloads
/// - Efficient bit manipulation
/// - Platform-optimized byte swapping
///
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)
public struct BinaryProtocol {
public static let v1HeaderSize = 14
static let v2HeaderSize = 16
public static let senderIDSize = 8
public static let recipientIDSize = 8
public static let signatureSize = 64
// Field offsets within packet header
public struct Offsets {
static let version = 0
static let type = 1
static let ttl = 2
static let timestamp = 3
public static let flags = 11 // After version(1) + type(1) + ttl(1) + timestamp(8)
}
public static func headerSize(for version: UInt8) -> Int? {
switch version {
case 1: return v1HeaderSize
case 2: return v2HeaderSize
default: return nil
}
}
private static func lengthFieldSize(for version: UInt8) -> Int {
return version == 2 ? 4 : 2
}
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
}
// Encode BitchatPacket to binary format
static func encode(_ packet: BitchatPacket, padding: Bool = true) -> Data? {
let version = packet.version
guard version == 1 || version == 2 else { return nil }
// Try to compress payload when beneficial, keeping original size for later decoding
var payload = packet.payload
var isCompressed = false
var originalPayloadSize: Int?
if CompressionUtil.shouldCompress(payload) {
// Only compress when we can represent the original length in the outbound frame
let maxRepresentable = version == 2 ? Int(UInt32.max) : Int(UInt16.max)
if payload.count <= maxRepresentable,
let compressedPayload = CompressionUtil.compress(payload) {
originalPayloadSize = payload.count
payload = compressedPayload
isCompressed = true
}
}
let lengthFieldBytes = lengthFieldSize(for: version)
// Route is only supported for v2+ packets (per SOURCE_ROUTING.md spec)
let originalRoute = (version >= 2) ? (packet.route ?? []) : []
if originalRoute.contains(where: { $0.isEmpty }) { return nil }
let sanitizedRoute: [Data] = originalRoute.map { hop in
if hop.count == senderIDSize { return hop }
if hop.count > senderIDSize { return Data(hop.prefix(senderIDSize)) }
var padded = hop
padded.append(Data(repeating: 0, count: senderIDSize - hop.count))
return padded
}
guard sanitizedRoute.count <= 255 else { return nil }
let hasRoute = !sanitizedRoute.isEmpty
let routeLength = hasRoute ? 1 + sanitizedRoute.count * senderIDSize : 0
let originalSizeFieldBytes = isCompressed ? lengthFieldBytes : 0
// payloadLength in header is payload-only (does NOT include route bytes)
let payloadDataSize = payload.count + originalSizeFieldBytes
if version == 1 && payloadDataSize > Int(UInt16.max) { return nil }
if version == 2 && payloadDataSize > Int(UInt32.max) { return nil }
guard let headerSize = headerSize(for: version) else { return nil }
let estimatedHeader = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize) + routeLength
let estimatedPayload = payloadDataSize
let estimatedSignature = (packet.signature == nil ? 0 : signatureSize)
var data = Data()
data.reserveCapacity(estimatedHeader + estimatedPayload + estimatedSignature + 255)
data.append(version)
data.append(packet.type)
data.append(packet.ttl)
for shift in stride(from: 56, through: 0, by: -8) {
data.append(UInt8((packet.timestamp >> UInt64(shift)) & 0xFF))
}
var flags: UInt8 = 0
if packet.recipientID != nil { flags |= Flags.hasRecipient }
if packet.signature != nil { flags |= Flags.hasSignature }
if isCompressed { flags |= Flags.isCompressed }
// HAS_ROUTE is only valid for v2+ packets
if hasRoute && version >= 2 { flags |= Flags.hasRoute }
if packet.isRSR { flags |= Flags.isRSR }
data.append(flags)
if version == 2 {
let length = UInt32(payloadDataSize)
for shift in stride(from: 24, through: 0, by: -8) {
data.append(UInt8((length >> UInt32(shift)) & 0xFF))
}
} else {
let length = UInt16(payloadDataSize)
data.append(UInt8((length >> 8) & 0xFF))
data.append(UInt8(length & 0xFF))
}
let senderBytes = packet.senderID.prefix(senderIDSize)
data.append(senderBytes)
if senderBytes.count < senderIDSize {
data.append(Data(repeating: 0, count: senderIDSize - senderBytes.count))
}
if let recipientID = packet.recipientID {
let recipientBytes = recipientID.prefix(recipientIDSize)
data.append(recipientBytes)
if recipientBytes.count < recipientIDSize {
data.append(Data(repeating: 0, count: recipientIDSize - recipientBytes.count))
}
}
if hasRoute {
data.append(UInt8(sanitizedRoute.count))
for hop in sanitizedRoute {
data.append(hop)
}
}
if isCompressed, let originalSize = originalPayloadSize {
if version == 2 {
let value = UInt32(originalSize)
for shift in stride(from: 24, through: 0, by: -8) {
data.append(UInt8((value >> UInt32(shift)) & 0xFF))
}
} else {
let value = UInt16(originalSize)
data.append(UInt8((value >> 8) & 0xFF))
data.append(UInt8(value & 0xFF))
}
}
data.append(payload)
if let signature = packet.signature {
data.append(signature.prefix(signatureSize))
}
if padding {
let optimalSize = MessagePadding.optimalBlockSize(for: data.count)
return MessagePadding.pad(data, toSize: optimalSize)
}
return data
}
// Decode binary data to 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
let unpadded = MessagePadding.unpad(data)
if unpadded as NSData === data as NSData { return nil }
return decodeCore(unpadded)
}
// Core decoding implementation used by decode(_:) with and without padding removal
private static func decodeCore(_ raw: Data) -> BitchatPacket? {
guard raw.count >= v1HeaderSize + senderIDSize else { return nil }
return raw.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> BitchatPacket? in
guard let base = buf.baseAddress else { return nil }
var offset = 0
func require(_ n: Int) -> Bool { offset + n <= buf.count }
func read8() -> UInt8? {
guard require(1) else { return nil }
let value = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self).pointee
offset += 1
return value
}
func read16() -> UInt16? {
guard require(2) else { return nil }
let ptr = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
let value = (UInt16(ptr[0]) << 8) | UInt16(ptr[1])
offset += 2
return value
}
func read32() -> UInt32? {
guard require(4) else { return nil }
let ptr = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
let value = (UInt32(ptr[0]) << 24) | (UInt32(ptr[1]) << 16) | (UInt32(ptr[2]) << 8) | UInt32(ptr[3])
offset += 4
return value
}
func readData(_ n: Int) -> Data? {
guard require(n) else { return nil }
let ptr = base.advanced(by: offset)
let data = Data(bytes: ptr, count: n)
offset += n
return data
}
guard let version = read8(), version == 1 || version == 2 else { return nil }
let lengthFieldBytes = lengthFieldSize(for: version)
guard let headerSize = headerSize(for: version) else { return nil }
let minimumRequired = headerSize + senderIDSize
guard raw.count >= minimumRequired else { return nil }
guard let type = read8(), let ttl = read8() else { return nil }
var timestamp: UInt64 = 0
for _ in 0..<8 {
guard let byte = read8() else { return nil }
timestamp = (timestamp << 8) | UInt64(byte)
}
guard let flags = read8() else { return nil }
let hasRecipient = (flags & Flags.hasRecipient) != 0
let hasSignature = (flags & Flags.hasSignature) != 0
let isCompressed = (flags & Flags.isCompressed) != 0
// HAS_ROUTE is only valid for v2+ packets; ignore the flag for v1
let hasRoute = (version >= 2) && (flags & Flags.hasRoute) != 0
let isRSR = (flags & Flags.isRSR) != 0
let payloadLength: Int
if version == 2 {
guard let len = read32() else { return nil }
payloadLength = Int(len)
} else {
guard let len = read16() else { return nil }
payloadLength = Int(len)
}
guard payloadLength >= 0 else { return nil }
guard payloadLength <= FileTransferLimits.maxFramedFileBytes else { return nil }
guard let senderID = readData(senderIDSize) else { return nil }
var recipientID: Data? = nil
if hasRecipient {
recipientID = readData(recipientIDSize)
if recipientID == nil { return nil }
}
// Route (optional, v2+ only): route bytes are NOT included in payloadLength
var route: [Data]? = nil
if hasRoute {
guard let routeCount = read8() else { return nil }
if routeCount > 0 {
var hops: [Data] = []
for _ in 0..<Int(routeCount) {
guard let hop = readData(senderIDSize) else { return nil }
hops.append(hop)
}
route = hops
}
}
// Payload: payloadLength is exactly the payload size (+ compression preamble if compressed)
let payload: Data
if isCompressed {
guard payloadLength >= lengthFieldBytes else { return nil }
let originalSize: Int
if version == 2 {
guard let rawSize = read32() else { return nil }
originalSize = Int(rawSize)
} else {
guard let rawSize = read16() else { return nil }
originalSize = Int(rawSize)
}
guard originalSize >= 0 && originalSize <= FileTransferLimits.maxFramedFileBytes else { return nil }
let compressedSize = payloadLength - lengthFieldBytes
guard compressedSize > 0, let compressed = readData(compressedSize) else { return nil }
let compressionRatio = Double(originalSize) / Double(compressedSize)
guard compressionRatio <= 50_000.0 else {
SecureLogger.warning("🚫 Suspicious compression ratio: \(String(format: "%.0f", compressionRatio)):1", category: .security)
return nil
}
guard let decompressed = CompressionUtil.decompress(compressed, originalSize: originalSize),
decompressed.count == originalSize else { return nil }
payload = decompressed
} else {
guard let rawPayload = readData(payloadLength) else { return nil }
payload = rawPayload
}
var signature: Data? = nil
if hasSignature {
signature = readData(signatureSize)
if signature == nil { return nil }
}
guard offset <= buf.count else { return nil }
return BitchatPacket(
type: type,
senderID: senderID,
recipientID: recipientID,
timestamp: timestamp,
payload: payload,
signature: signature,
ttl: ttl,
version: version,
route: route,
isRSR: isRSR
)
}
}
}
@@ -0,0 +1,361 @@
//
// BitchatMessage.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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
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] = [:]
public func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? {
return _cachedFormattedText["\(isDark)-\(isSelf)"]
}
public func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool) {
_cachedFormattedText["\(isDark)-\(isSelf)"] = text
}
// Codable implementation
enum CodingKeys: String, CodingKey {
case id, sender, content, timestamp, isRelay, originalSender
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus
}
public init(
id: String? = nil,
sender: String,
content: String,
timestamp: Date,
isRelay: Bool,
originalSender: String? = nil,
isPrivate: Bool = false,
recipientNickname: String? = nil,
senderPeerID: PeerID? = nil,
mentions: [String]? = nil,
deliveryStatus: DeliveryStatus? = nil
) {
self.id = id ?? UUID().uuidString
self.sender = sender
self.content = content
self.timestamp = timestamp
self.isRelay = isRelay
self.originalSender = originalSender
self.isPrivate = isPrivate
self.recipientNickname = recipientNickname
self.senderPeerID = senderPeerID
self.mentions = mentions
self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil)
}
}
// MARK: - Equatable Conformance
extension BitchatMessage: Equatable {
public static func == (lhs: BitchatMessage, rhs: BitchatMessage) -> Bool {
return lhs.id == rhs.id &&
lhs.sender == rhs.sender &&
lhs.content == rhs.content &&
lhs.timestamp == rhs.timestamp &&
lhs.isRelay == rhs.isRelay &&
lhs.originalSender == rhs.originalSender &&
lhs.isPrivate == rhs.isPrivate &&
lhs.recipientNickname == rhs.recipientNickname &&
lhs.senderPeerID == rhs.senderPeerID &&
lhs.mentions == rhs.mentions &&
lhs.deliveryStatus == rhs.deliveryStatus
}
}
// MARK: - Binary encoding
extension BitchatMessage {
func toBinaryPayload() -> Data? {
var data = Data()
// Message format:
// - Flags: 1 byte (bit 0: isRelay, bit 1: isPrivate, bit 2: hasOriginalSender, bit 3: hasRecipientNickname, bit 4: hasSenderPeerID, bit 5: hasMentions)
// - Timestamp: 8 bytes (seconds since epoch)
// - ID length: 1 byte
// - ID: variable
// - Sender length: 1 byte
// - Sender: variable
// - Content length: 2 bytes
// - Content: variable
// Optional fields based on flags:
// - Original sender length + data
// - Recipient nickname length + data
// - Sender peer ID length + data
// - Mentions array
var flags: UInt8 = 0
if isRelay { flags |= 0x01 }
if isPrivate { flags |= 0x02 }
if originalSender != nil { flags |= 0x04 }
if recipientNickname != nil { flags |= 0x08 }
if senderPeerID != nil { flags |= 0x10 }
if mentions != nil && !mentions!.isEmpty { flags |= 0x20 }
data.append(flags)
// Timestamp (in milliseconds)
let timestampMillis = UInt64(timestamp.timeIntervalSince1970 * 1000)
// Encode as 8 bytes, big-endian
for i in (0..<8).reversed() {
data.append(UInt8((timestampMillis >> (i * 8)) & 0xFF))
}
// ID
if let idData = id.data(using: .utf8) {
data.append(UInt8(min(idData.count, 255)))
data.append(idData.prefix(255))
} else {
data.append(0)
}
// Sender
if let senderData = sender.data(using: .utf8) {
data.append(UInt8(min(senderData.count, 255)))
data.append(senderData.prefix(255))
} else {
data.append(0)
}
// Content
if let contentData = content.data(using: .utf8) {
let length = UInt16(min(contentData.count, 65535))
// Encode length as 2 bytes, big-endian
data.append(UInt8((length >> 8) & 0xFF))
data.append(UInt8(length & 0xFF))
data.append(contentData.prefix(Int(length)))
} else {
data.append(contentsOf: [0, 0])
}
// Optional fields
if let originalSender = originalSender, let origData = originalSender.data(using: .utf8) {
data.append(UInt8(min(origData.count, 255)))
data.append(origData.prefix(255))
}
if let recipientNickname = recipientNickname, let recipData = recipientNickname.data(using: .utf8) {
data.append(UInt8(min(recipData.count, 255)))
data.append(recipData.prefix(255))
}
if let peerData = senderPeerID?.id.data(using: .utf8) {
data.append(UInt8(min(peerData.count, 255)))
data.append(peerData.prefix(255))
}
// Mentions array
if let mentions = mentions {
data.append(UInt8(min(mentions.count, 255))) // Number of mentions
for mention in mentions.prefix(255) {
if let mentionData = mention.data(using: .utf8) {
data.append(UInt8(min(mentionData.count, 255)))
data.append(mentionData.prefix(255))
} else {
data.append(0)
}
}
}
return data
}
convenience init?(_ data: Data) {
// Create an immutable copy to prevent threading issues
let dataCopy = Data(data)
guard dataCopy.count >= 13 else {
return nil
}
var offset = 0
// Flags
guard offset < dataCopy.count else {
return nil
}
let flags = dataCopy[offset]; offset += 1
let isRelay = (flags & 0x01) != 0
let isPrivate = (flags & 0x02) != 0
let hasOriginalSender = (flags & 0x04) != 0
let hasRecipientNickname = (flags & 0x08) != 0
let hasSenderPeerID = (flags & 0x10) != 0
let hasMentions = (flags & 0x20) != 0
// Timestamp
guard offset + 8 <= dataCopy.count else {
return nil
}
let timestampData = dataCopy[offset..<offset+8]
let timestampMillis = timestampData.reduce(0) { result, byte in
(result << 8) | UInt64(byte)
}
offset += 8
let timestamp = Date(timeIntervalSince1970: TimeInterval(timestampMillis) / 1000.0)
// ID
guard offset < dataCopy.count else {
return nil
}
let idLength = Int(dataCopy[offset]); offset += 1
guard offset + idLength <= dataCopy.count else {
return nil
}
let id = String(data: dataCopy[offset..<offset+idLength], encoding: .utf8) ?? UUID().uuidString
offset += idLength
// Sender
guard offset < dataCopy.count else {
return nil
}
let senderLength = Int(dataCopy[offset]); offset += 1
guard offset + senderLength <= dataCopy.count else {
return nil
}
let sender = String(data: dataCopy[offset..<offset+senderLength], encoding: .utf8) ?? "unknown"
offset += senderLength
// Content
guard offset + 2 <= dataCopy.count else {
return nil
}
let contentLengthData = dataCopy[offset..<offset+2]
let contentLength = Int(contentLengthData.reduce(0) { result, byte in
(result << 8) | UInt16(byte)
})
offset += 2
guard offset + contentLength <= dataCopy.count else {
return nil
}
let content = String(data: dataCopy[offset..<offset+contentLength], encoding: .utf8) ?? ""
offset += contentLength
// Optional fields
var originalSender: String?
if hasOriginalSender && offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
originalSender = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
offset += length
}
}
var recipientNickname: String?
if hasRecipientNickname && offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
recipientNickname = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
offset += length
}
}
var senderPeerID: PeerID?
if hasSenderPeerID && offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
senderPeerID = PeerID(data: dataCopy[offset..<offset+length])
offset += length
}
}
// Mentions array
var mentions: [String]?
if hasMentions && offset < dataCopy.count {
let mentionCount = Int(dataCopy[offset]); offset += 1
if mentionCount > 0 {
mentions = []
for _ in 0..<mentionCount {
if offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
if let mention = String(data: dataCopy[offset..<offset+length], encoding: .utf8) {
mentions?.append(mention)
}
offset += length
}
}
}
}
}
self.init(
id: id,
sender: sender,
content: content,
timestamp: timestamp,
isRelay: isRelay,
originalSender: originalSender,
isPrivate: isPrivate,
recipientNickname: recipientNickname,
senderPeerID: senderPeerID,
mentions: mentions
)
}
}
// MARK: - Helpers
extension BitchatMessage {
private static let timestampFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
return formatter
}()
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)
public func cleanedAndDeduped() -> [Element] {
let arr = filter { $0.content.trimmed.isEmpty == false }
guard arr.count > 1 else {
return arr
}
var seen = Set<String>()
var dedup: [BitchatMessage] = []
for m in arr.sorted(by: { $0.timestamp < $1.timestamp }) {
if !seen.contains(m.id) {
dedup.append(m)
seen.insert(m.id)
}
}
return dedup
}
}
@@ -0,0 +1,101 @@
//
// BitchatPacket.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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
public struct BitchatPacket: Codable {
let version: UInt8
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
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
self.recipientID = recipientID
self.timestamp = timestamp
self.payload = payload
self.signature = signature
self.ttl = ttl
self.route = route
self.isRSR = isRSR
}
// Convenience initializer for new binary format
init(type: UInt8, ttl: UInt8, senderID: PeerID, payload: Data, isRSR: Bool = false) {
self.version = 1
self.type = type
// Convert hex string peer ID to binary data (8 bytes)
var senderData = Data()
var tempID = senderID.id
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
self.route = nil
self.isRSR = isRSR
}
var data: Data? {
BinaryProtocol.encode(self)
}
public func toBinaryData(padding: Bool = true) -> Data? {
BinaryProtocol.encode(self, padding: padding)
}
// Backward-compatible helper (defaults to padded encoding)
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
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(
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
version: version,
route: route,
isRSR: false // RSR flag is mutable and not part of the signature
)
return BinaryProtocol.encode(unsignedPacket)
}
public static func from(_ data: Data) -> BitchatPacket? {
BinaryProtocol.decode(data)
}
}
@@ -0,0 +1,73 @@
//
// CompressionUtil.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import struct Foundation.Data
private import Compression
struct CompressionUtil {
// Compression threshold - don't compress if data is smaller than this
static let compressionThreshold = Constants.compressionThresholdBytes // bytes
// Compress data using zlib algorithm (most compatible)
static func compress(_ data: Data) -> Data? {
// Skip compression for small data
guard data.count >= compressionThreshold else { return nil }
let maxCompressedSize = data.count + (data.count / 255) + 16
let destinationBuffer = UnsafeMutablePointer<UInt8>.allocate(capacity: maxCompressedSize)
defer { destinationBuffer.deallocate() }
let compressedSize = data.withUnsafeBytes { sourceBuffer in
guard let sourcePtr = sourceBuffer.bindMemory(to: UInt8.self).baseAddress else { return 0 }
return compression_encode_buffer(
destinationBuffer, data.count,
sourcePtr, data.count,
nil, COMPRESSION_ZLIB
)
}
guard compressedSize > 0 && compressedSize < data.count else { return nil }
return Data(bytes: destinationBuffer, count: compressedSize)
}
// Decompress zlib compressed data
static func decompress(_ compressedData: Data, originalSize: Int) -> Data? {
let destinationBuffer = UnsafeMutablePointer<UInt8>.allocate(capacity: originalSize)
defer { destinationBuffer.deallocate() }
let decompressedSize = compressedData.withUnsafeBytes { sourceBuffer in
guard let sourcePtr = sourceBuffer.bindMemory(to: UInt8.self).baseAddress else { return 0 }
return compression_decode_buffer(
destinationBuffer, originalSize,
sourcePtr, compressedData.count,
nil, COMPRESSION_ZLIB
)
}
guard decompressedSize > 0 else { return nil }
return Data(bytes: destinationBuffer, count: decompressedSize)
}
// Helper to check if compression is worth it
static func shouldCompress(_ data: Data) -> Bool {
// Don't compress if:
// 1. Data is too small
// 2. Data appears to be already compressed (high entropy)
guard data.count >= compressionThreshold else { return false }
// Quick uniqueness check a high diversity of bytes usually means the
// payload is already compressed. We only need to know how many unique
// values exist rather than keeping full frequency counts.
let uniqueByteCount = Set(data).count
let sampleSize = min(data.count, 256)
let uniqueByteRatio = Double(uniqueByteCount) / Double(sampleSize)
return uniqueByteRatio < 0.9 // Compress if less than 90% unique bytes
}
}
@@ -0,0 +1,12 @@
//
// Constants.swift
// BitFoundation
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
enum Constants {
// Compression
static let compressionThresholdBytes: Int = 100
}
@@ -0,0 +1,35 @@
//
// DeliveryStatus.swift
// BitFoundation
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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)"
}
}
}
@@ -0,0 +1,23 @@
/// Centralized thresholds for Bluetooth file transfers to keep payload sizes sane on constrained radios.
public enum FileTransferLimits {
/// Absolute ceiling enforced for any file payload (voice, image, other).
public static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB
/// Voice notes stay small for low-latency relays.
public static let maxVoiceNoteBytes: Int = 512 * 1024 // 512 KiB
/// Compressed images after downscaling should comfortably fit under this budget.
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.
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
+ BinaryProtocol.senderIDSize
+ BinaryProtocol.recipientIDSize
+ BinaryProtocol.signatureSize
return maxPayloadBytes + tlvEnvelopeOverhead + binaryEnvelopeOverhead
}()
public static func isValidPayload(_ size: Int) -> Bool {
size <= maxPayloadBytes
}
}
@@ -0,0 +1,78 @@
//
// KeychainManagerProtocol.swift
// BitFoundation
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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
}
}
}
@@ -0,0 +1,61 @@
//
// MessagePadding.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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.
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[..<start])
}
// Find optimal block size for data
static func optimalBlockSize(for dataSize: Int) -> 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
}
}
@@ -0,0 +1,39 @@
//
// MessageType.swift
// BitFoundation
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
/// 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"
}
}
}
@@ -0,0 +1,33 @@
//
// BinaryProtocolPaddingTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
//
import Testing
@testable import BitFoundation
struct BinaryProtocolPaddingTests {
@Test func padded_vs_unpadded_length() throws {
// Use helper to create a small test packet
let packet = TestHelpers.createTestPacket()
let padded = try #require(BinaryProtocol.encode(packet, padding: true), "encode padded")
let unpadded = try #require(BinaryProtocol.encode(packet, padding: false), "encode unpadded")
#expect(padded.count >= unpadded.count, "Padded frame should be >= unpadded")
}
@Test func decode_padded_and_unpadded_round_trip() throws {
let packet = TestHelpers.createTestPacket()
let padded = try #require(BinaryProtocol.encode(packet, padding: true), "encode padded")
let dec1 = try #require(BinaryProtocol.decode(padded), "decode padded")
#expect(dec1.type == packet.type)
#expect(dec1.payload == packet.payload)
let unpadded = try #require(BinaryProtocol.encode(packet, padding: false), "encode unpadded")
let dec2 = try #require(BinaryProtocol.decode(unpadded), "decode unpadded")
#expect(dec2.type == packet.type)
#expect(dec2.payload == packet.payload)
}
}
@@ -0,0 +1,817 @@
//
// BinaryProtocolTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import Foundation
@testable import BitFoundation
struct BinaryProtocolTests {
// MARK: - Basic Encoding/Decoding Tests
@Test func basicPacketEncodingDecoding() throws {
let originalPacket = TestHelpers.createTestPacket()
let encodedData = try #require(BinaryProtocol.encode(originalPacket), "Failed to encode packet")
let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode packet")
// Verify
#expect(decodedPacket.type == originalPacket.type)
#expect(decodedPacket.ttl == originalPacket.ttl)
#expect(decodedPacket.timestamp == originalPacket.timestamp)
#expect(decodedPacket.payload == originalPacket.payload)
// Sender ID should match (accounting for padding)
let originalSenderID = originalPacket.senderID.prefix(BinaryProtocol.senderIDSize)
let decodedSenderID = decodedPacket.senderID.trimmingNullBytes()
#expect(decodedSenderID == originalSenderID)
}
@Test func trimmingNullBytesReturnsOriginalDataWhenNoNullsPresent() {
let raw = Data([0x41, 0x42, 0x43])
#expect(raw.trimmingNullBytes() == raw)
}
@Test func packetWithRecipient() throws {
let recipientID = PeerID(str: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789")
let packet = TestHelpers.createTestPacket(recipientID: recipientID)
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with recipient")
let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode packet with recipient")
// Verify recipient
#expect(decodedPacket.recipientID != nil)
let decodedRecipientID = decodedPacket.recipientID?.trimmingNullBytes()
// TODO: Check if this is intended that the decoding only gets the first 8
#expect(String(data: decodedRecipientID!, encoding: .utf8) == "abcdef01")
}
@Test func packetWithSignature() throws {
let packet = TestHelpers.createTestPacket(signature: TestConstants.testSignature)
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with signature")
let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode packet with signature")
// Verify signature
#expect(decodedPacket.signature != nil)
#expect(decodedPacket.signature == TestConstants.testSignature)
}
// MARK: - Source-Based Routing Tests (v2 only)
@Test func packetWithRouteRoundTrip() throws {
let route: [Data] = [
try #require(Data(hexString: "0102030405060708")),
try #require(Data(hexString: "1112131415161718")),
try #require(Data(hexString: "2122232425262728"))
]
// Route is only supported for v2+ packets
var packet = BitchatPacket(
type: 0x01,
senderID: route[0],
recipientID: route.last,
timestamp: 1_720_000_000_000,
payload: Data("route-test".utf8),
signature: nil,
ttl: 6,
version: 2
)
packet.route = route
let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with route")
let flagsByte = encoded[BinaryProtocol.Offsets.flags]
#expect((flagsByte & BinaryProtocol.Flags.hasRoute) != 0)
let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode packet with route")
#expect(decoded.version == 2)
let decodedRoute = try #require(decoded.route)
#expect(decodedRoute.count == route.count)
for (expected, actual) in zip(route, decodedRoute) {
#expect(actual == expected)
}
}
@Test func packetWithRoutePadsShortHop() throws {
let sender = try #require(Data(hexString: "0011223344556677"))
let destination = try #require(Data(hexString: "8899aabbccddeeff"))
let shortHop = Data([0xAA, 0xBB, 0xCC])
// Route is only supported for v2+ packets
var packet = BitchatPacket(
type: 0x02,
senderID: sender,
recipientID: destination,
timestamp: 1_730_000_000_000,
payload: Data("pad-test".utf8),
signature: nil,
ttl: 5,
version: 2
)
packet.route = [shortHop, destination]
let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with short hop route")
let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode packet with short hop route")
let decodedRoute = try #require(decoded.route)
let firstHop = try #require(decodedRoute.first)
#expect(firstHop.count == BinaryProtocol.senderIDSize)
#expect(firstHop.prefix(shortHop.count) == shortHop)
let paddingBytes = firstHop.suffix(firstHop.count - shortHop.count)
#expect(paddingBytes.allSatisfy { $0 == 0 })
}
@Test func packetWithRouteAndCompressedPayload() throws {
let route: [Data] = [
try #require(Data(hexString: "0101010101010101")),
try #require(Data(hexString: "0202020202020202"))
]
let repeatedString = String(repeating: "compress-me", count: 150)
// Route is only supported for v2+ packets
var packet = BitchatPacket(
type: 0x03,
senderID: route[0],
recipientID: route.last,
timestamp: 1_740_000_000_000,
payload: Data(repeatedString.utf8),
signature: nil,
ttl: 7,
version: 2
)
packet.route = route
let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with route and compression")
let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode packet with route and compression")
#expect(decoded.payload == Data(repeatedString.utf8))
let decodedRoute = try #require(decoded.route)
#expect(decodedRoute == route)
}
@Test func v1PacketIgnoresRouteOnEncode() throws {
// v1 packets should NOT include route even if route is set on the packet object
let route: [Data] = [
try #require(Data(hexString: "0102030405060708")),
try #require(Data(hexString: "1112131415161718"))
]
var packet = BitchatPacket(
type: 0x01,
senderID: route[0],
recipientID: route.last,
timestamp: 1_720_000_000_000,
payload: Data("v1-no-route".utf8),
signature: nil,
ttl: 6
// version defaults to 1 (v1 packet)
)
packet.route = route // route is set but should be ignored for v1
let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode v1 packet")
// HAS_ROUTE flag should NOT be set for v1 packets
let flagsByte = encoded[BinaryProtocol.Offsets.flags]
#expect((flagsByte & BinaryProtocol.Flags.hasRoute) == 0, "v1 packet should not have HAS_ROUTE flag set")
// Decoded packet should have no route
let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode v1 packet")
#expect(decoded.version == 1)
#expect(decoded.route == nil, "v1 packet should decode with nil route")
#expect(decoded.payload == Data("v1-no-route".utf8))
}
@Test func v2PacketIncludesRouteOnEncode() throws {
// v2 packets SHOULD include route when route is set
let route: [Data] = [
try #require(Data(hexString: "0102030405060708")),
try #require(Data(hexString: "1112131415161718"))
]
var packet = BitchatPacket(
type: 0x01,
senderID: route[0],
recipientID: route.last,
timestamp: 1_720_000_000_000,
payload: Data("v2-with-route".utf8),
signature: nil,
ttl: 6,
version: 2
)
packet.route = route
let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode v2 packet")
// HAS_ROUTE flag SHOULD be set for v2 packets with route
let flagsByte = encoded[BinaryProtocol.Offsets.flags]
#expect((flagsByte & BinaryProtocol.Flags.hasRoute) != 0, "v2 packet should have HAS_ROUTE flag set")
// Decoded packet should have route
let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode v2 packet")
#expect(decoded.version == 2)
let decodedRoute = try #require(decoded.route, "v2 packet should decode with route")
#expect(decodedRoute.count == route.count)
#expect(decoded.payload == Data("v2-with-route".utf8))
}
@Test func v2PacketWithoutRouteDecodesCorrectly() throws {
// v2 packet without route should still work
let sender = try #require(Data(hexString: "0011223344556677"))
let recipient = try #require(Data(hexString: "8899aabbccddeeff"))
let packet = BitchatPacket(
type: 0x02,
senderID: sender,
recipientID: recipient,
timestamp: 1_750_000_000_000,
payload: Data("v2-no-route".utf8),
signature: nil,
ttl: 5,
version: 2
)
// route is nil by default
let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode v2 packet without route")
// HAS_ROUTE flag should NOT be set when no route
let flagsByte = encoded[BinaryProtocol.Offsets.flags]
#expect((flagsByte & BinaryProtocol.Flags.hasRoute) == 0, "v2 packet without route should not have HAS_ROUTE flag")
let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode v2 packet without route")
#expect(decoded.version == 2)
#expect(decoded.route == nil)
#expect(decoded.payload == Data("v2-no-route".utf8))
}
@Test func v1AndV2PayloadLengthDifference() throws {
// Verify that payloadLength does NOT include route bytes
// by comparing encoded sizes
let route: [Data] = [
try #require(Data(hexString: "0102030405060708"))
]
let payloadData = Data("test-payload".utf8)
// v1 packet (route ignored)
var v1Packet = BitchatPacket(
type: 0x01,
senderID: route[0],
recipientID: nil,
timestamp: 1_720_000_000_000,
payload: payloadData,
signature: nil,
ttl: 6
// version defaults to 1
)
v1Packet.route = route // will be ignored for v1
// v2 packet with same payload but route included
var v2Packet = BitchatPacket(
type: 0x01,
senderID: route[0],
recipientID: nil,
timestamp: 1_720_000_000_000,
payload: payloadData,
signature: nil,
ttl: 6,
version: 2
)
v2Packet.route = route
let v1Encoded = try #require(BinaryProtocol.encode(v1Packet, padding: false))
let v2Encoded = try #require(BinaryProtocol.encode(v2Packet, padding: false))
// v2 should be larger by: 2 bytes (header length field difference) + 1 byte (route count) + 8 bytes (one hop)
// Header: v1=14, v2=16 -> +2 bytes
// Route: 1 + 8 = 9 bytes
// Total expected difference: 11 bytes
let expectedDiff = 2 + 1 + 8 // header diff + route count + one hop
#expect(v2Encoded.count - v1Encoded.count == expectedDiff,
"v2 packet should be \(expectedDiff) bytes larger than v1 (actual diff: \(v2Encoded.count - v1Encoded.count))")
}
// MARK: - Compression Tests
@Test("Create a large, compressible payload above current threshold (2048B)")
func payloadCompression() throws {
let repeatedString = String(repeating: "This is a test message. ", count: 200)
let largePayload = repeatedString.data(using: .utf8)!
let packet = TestHelpers.createTestPacket(payload: largePayload)
// Encode (should compress)
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with large payload")
// The encoded size should be smaller than uncompressed due to compression
let headerSize = try #require(BinaryProtocol.headerSize(for: packet.version), "Invalid packet version")
let uncompressedSize = headerSize + BinaryProtocol.senderIDSize + largePayload.count
#expect(encodedData.count < uncompressedSize, "Compressed packet should be smaller than uncompressed form")
// Decode and verify
let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode compressed packet")
#expect(decodedPacket.payload == largePayload)
}
@Test("Small payloads should not be compressed")
func smallPayloadNoCompression() throws {
let smallPayload = "Hi".data(using: .utf8)!
let packet = TestHelpers.createTestPacket(payload: smallPayload)
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode small packet")
let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode small packet")
#expect(decodedPacket.payload == smallPayload)
}
@Test("Reject payloads larger than the framed file cap")
func oversizedPayloadIsRejected() throws {
let targetSize = FileTransferLimits.maxFramedFileBytes + 1
var oversized = Data()
oversized.reserveCapacity(targetSize)
let byteRun = Data((0...255).map { UInt8($0) })
while oversized.count < targetSize {
let remaining = targetSize - oversized.count
if remaining >= byteRun.count {
oversized.append(byteRun)
} else {
oversized.append(byteRun.prefix(remaining))
}
}
let packet = BitchatPacket(
type: MessageType.message.rawValue,
senderID: Data(hexString: "0011223344556677") ?? Data(),
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: oversized,
signature: nil,
ttl: 1,
version: 2
)
let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode oversized packet")
#expect(BinaryProtocol.decode(encoded) == nil)
}
// MARK: - Message Padding Tests
@Test func messagePadding() throws {
let payloads = [
"Short",
String(repeating: "Medium length message content ", count: 10), // ~300 bytes
String(repeating: "Long message content that should exceed the 512 byte limit ", count: 20), // ~1200+ bytes
String(repeating: "Very long message content that should definitely exceed the 2048 byte limit for sure ", count: 30) // ~2700+ bytes
]
var encodedSizes = Set<Int>()
for payload in payloads {
let packet = TestHelpers.createTestPacket(payload: payload.data(using: .utf8)!)
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode packet")
// Verify padding creates standard block sizes up to configured limit (no 4096 bucket currently)
let blockSizes = [256, 512, 1024, 2048]
if encodedData.count <= 2048 {
#expect(blockSizes.contains(encodedData.count), "Encoded size \(encodedData.count) is not a standard block size")
} else {
// For very large payloads we expect no additional padding beyond raw size
#expect(encodedData.count > 2048)
}
encodedSizes.insert(encodedData.count)
// Verify decoding works
let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode padded packet")
#expect(String(data: decodedPacket.payload, encoding: .utf8) == payload)
}
// Different payload sizes (within <=2048) may map to the same bucket depending on compression.
// Require at least one padded size to be present.
#expect(encodedSizes.filter { $0 <= 2048 }.count >= 1, "Expected at least one padded size up to 2048, got \(encodedSizes)")
}
@Test func invalidPKCS7PaddingIsRejected() throws {
let pkt = TestHelpers.createTestPacket(payload: Data(repeating: 0x41, count: 50)) // small
let enc0 = try #require(BinaryProtocol.encode(pkt), "encode failed")
// Force padding to known block for test stability
var enc = MessagePadding.pad(enc0, toSize: 256)
let unpadded = MessagePadding.unpad(enc)
let padLen = enc.count - unpadded.count
if padLen > 0 {
// Set last pad byte to wrong value (padLen-1) to break PKCS#7
enc[enc.count - 1] = UInt8((padLen - 1) & 0xFF)
let maybe = BinaryProtocol.decode(enc)
// If decode still succeeds (nested pad edge case), at least ensure payload integrity
if let pkt2 = maybe {
#expect(pkt2.payload == pkt.payload)
} else {
#expect(maybe == nil)
}
} else {
// If no padding was applied, just assert decode succeeds (nothing to test)
#expect(BinaryProtocol.decode(enc) != nil)
}
}
// MARK: - Message Encoding/Decoding Tests
@Test func messageEncodingDecoding() throws {
let message = TestHelpers.createTestMessage()
let payload = try #require(message.toBinaryPayload(), "Failed to encode message to binary")
let decodedMessage = try #require(BitchatMessage(payload), "Failed to decode message from binary")
#expect(decodedMessage.content == message.content)
#expect(decodedMessage.sender == message.sender)
#expect(decodedMessage.senderPeerID == message.senderPeerID)
#expect(decodedMessage.isPrivate == message.isPrivate)
// Timestamp should be close (within 1 second due to conversion)
let timeDiff = abs(decodedMessage.timestamp.timeIntervalSince(message.timestamp))
#expect(timeDiff < 1)
}
func testPrivateMessageEncoding() throws {
let message = TestHelpers.createTestMessage(
isPrivate: true,
recipientNickname: TestConstants.testNickname2
)
let payload = try #require(message.toBinaryPayload(), "Failed to encode private message")
let decodedMessage = try #require(BitchatMessage(payload), "Failed to decode private message")
#expect(decodedMessage.isPrivate)
#expect(decodedMessage.recipientNickname == TestConstants.testNickname2)
}
@Test func messageWithMentions() throws {
let mentions = [TestConstants.testNickname2, TestConstants.testNickname3]
let message = TestHelpers.createTestMessage(mentions: mentions)
let payload = try #require(message.toBinaryPayload(), "Failed to encode message with mentions")
let decodedMessage = try #require(BitchatMessage(payload), "Failed to decode message with mentions")
#expect(decodedMessage.mentions == mentions)
}
@Test func relayMessageEncoding() throws {
let message = BitchatMessage(
id: UUID().uuidString,
sender: TestConstants.testNickname1,
content: TestConstants.testMessage1,
timestamp: Date(),
isRelay: true,
originalSender: TestConstants.testNickname3,
isPrivate: false,
recipientNickname: nil,
mentions: nil
)
let payload = try #require(message.toBinaryPayload(), "Failed to encode relay message")
let decodedMessage = try #require(BitchatMessage(payload), "Failed to decode relay message")
#expect(decodedMessage.isRelay)
#expect(decodedMessage.originalSender == TestConstants.testNickname3)
}
// MARK: - Edge Cases and Error Handling
@Test("Too small data")
func invalidDataDecoding() throws {
let tooSmall = Data(repeating: 0, count: 5)
#expect(BinaryProtocol.decode(tooSmall) == nil)
// Random data
let random = TestHelpers.generateRandomData(length: 100)
#expect(BinaryProtocol.decode(random) == nil)
// Corrupted header
let packet = TestHelpers.createTestPacket()
var encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode test packet")
// Corrupt the version byte
encoded[0] = 0xFF
#expect(BinaryProtocol.decode(encoded) == nil)
}
@Test("Test maximum size handling")
func largeMessageHandling() throws {
let largeContent = String(repeating: "X", count: 65535) // Max uint16
let message = TestHelpers.createTestMessage(content: largeContent)
let payload = try #require(message.toBinaryPayload(), "Failed to handle large message")
let decodedMessage = try #require(BitchatMessage(payload), "Failed to handle large message")
#expect(decodedMessage.content == largeContent)
}
@Test("Test message with empty content")
func emptyFieldsHandling() throws {
let emptyMessage = TestHelpers.createTestMessage(content: "")
let payload = try #require(emptyMessage.toBinaryPayload(), "Failed to handle empty message")
let decodedMessage = try #require(BitchatMessage(payload), "Failed to handle empty message")
#expect(decodedMessage.content.isEmpty)
}
// MARK: - Protocol Version Tests
@Test("Test with supported version (version is always 1 in init)")
func protocolVersionHandling() throws {
let packet = TestHelpers.createTestPacket()
let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with version")
let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode packet with version")
#expect(decoded.version == 1)
}
@Test("Create packet data with unsupported version")
func unsupportedProtocolVersion() throws {
let packet = TestHelpers.createTestPacket()
var encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode packet")
// Manually change version byte to unsupported value
encoded[0] = 99 // Unsupported version
// Should fail to decode
#expect(BinaryProtocol.decode(encoded) == nil)
}
// MARK: - Bounds Checking Tests (Crash Prevention)
@Test("Test the specific crash scenario: payloadLength = 193 (0xc1) but only 30 bytes available")
func malformedPacketWithInvalidPayloadLength() throws {
var malformedData = Data()
// Valid header (13 bytes)
malformedData.append(1) // version
malformedData.append(1) // type
malformedData.append(10) // ttl
// Timestamp (8 bytes)
for _ in 0..<8 {
malformedData.append(0)
}
malformedData.append(0) // flags (no recipient, no signature, not compressed)
// Invalid payload length: 193 (0x00c1) but we'll only provide 8 bytes total data
malformedData.append(0x00) // high byte
malformedData.append(0xc1) // low byte (193)
// SenderID (8 bytes) - this brings us to 21 bytes total
for _ in 0..<8 {
malformedData.append(0x01)
}
// Only provide 8 more bytes instead of the claimed 193
for _ in 0..<8 {
malformedData.append(0x02)
}
// Total data is now 30 bytes, but payloadLength claims 193
#expect(malformedData.count == 30)
// This should not crash - should return nil gracefully
let result = BinaryProtocol.decode(malformedData)
#expect(result == nil, "Malformed packet with invalid payload length should return nil, not crash")
}
@Test("Test various truncation scenarios")
func truncatedPacketHandling() throws {
let packet = TestHelpers.createTestPacket()
let validEncoded = try #require(BinaryProtocol.encode(packet), "Failed to encode test packet")
// Test truncation at various points
let truncationPoints = [0, 5, 10, 15, 20, 25]
for point in truncationPoints {
let truncated = validEncoded.prefix(point)
let result = BinaryProtocol.decode(truncated)
#expect(result == nil, "Truncated packet at \(point) bytes should return nil, not crash")
}
}
@Test("Test compressed packet with invalid original size")
func malformedCompressedPacket() throws {
var malformedData = Data()
// Valid header
malformedData.append(1) // version
malformedData.append(1) // type
malformedData.append(10) // ttl
// Timestamp (8 bytes)
for _ in 0..<8 {
malformedData.append(0)
}
malformedData.append(0x04) // flags: isCompressed = true
// Small payload length that's insufficient for compression
malformedData.append(0x00) // high byte
malformedData.append(0x01) // low byte (1 byte - insufficient for 2-byte original size)
// SenderID (8 bytes)
for _ in 0..<8 {
malformedData.append(0x01)
}
// Only 1 byte of "compressed" data (should need at least 2 for original size)
malformedData.append(0x99)
// Should handle this gracefully
let result = BinaryProtocol.decode(malformedData)
#expect(result == nil, "Malformed compressed packet should return nil, not crash")
}
@Test("Test packet claiming extremely large payload")
func excessivelyLargePayloadLength() throws {
var malformedData = Data()
// Valid header
malformedData.append(1) // version
malformedData.append(1) // type
malformedData.append(10) // ttl
// Timestamp (8 bytes)
for _ in 0..<8 {
malformedData.append(0)
}
malformedData.append(0) // flags
// Maximum payload length (65535)
malformedData.append(0xFF) // high byte
malformedData.append(0xFF) // low byte
// SenderID (8 bytes)
for _ in 0..<8 {
malformedData.append(0x01)
}
// Provide only a tiny amount of actual data
malformedData.append(contentsOf: [0x01, 0x02, 0x03])
// Should handle this gracefully without trying to allocate massive amounts of memory
let result = BinaryProtocol.decode(malformedData)
#expect(result == nil, "Packet with excessive payload length should return nil, not crash")
}
@Test("Test compressed packet with unreasonable original size")
func compressedPacketWithInvalidOriginalSize() throws {
var malformedData = Data()
// Valid header
malformedData.append(1) // version
malformedData.append(1) // type
malformedData.append(10) // ttl
// Timestamp (8 bytes)
for _ in 0..<8 {
malformedData.append(0)
}
malformedData.append(0x04) // flags: isCompressed = true
// Reasonable payload length
malformedData.append(0x00) // high byte
malformedData.append(0x10) // low byte (16 bytes)
// SenderID (8 bytes)
for _ in 0..<8 {
malformedData.append(0x01)
}
// Original size claiming to be extremely large (2MB)
malformedData.append(0x20) // high byte of original size
malformedData.append(0x00) // low byte of original size (0x2000 = 8192, but let's make it larger with more bytes)
// Add more bytes to make it claim larger size - but this will be invalid
// because our validation should catch unreasonable sizes
malformedData.append(contentsOf: [0x01, 0x02, 0x03, 0x04]) // Some compressed data
// Pad to match payload length
while malformedData.count < 21 + 16 { // header + senderID + payload
malformedData.append(0x00)
}
let result = BinaryProtocol.decode(malformedData)
#expect(result == nil, "Compressed packet with invalid original size should return nil, not crash")
}
@Test("Test compressed packet with suspicious compression ratio")
func compressedPacketWithSuspiciousCompressionRatio() {
var malformedData = Data()
malformedData.append(1) // version
malformedData.append(1) // type
malformedData.append(10) // ttl
for _ in 0..<8 {
malformedData.append(0)
}
malformedData.append(0x04) // isCompressed
malformedData.append(0x00)
malformedData.append(0x03) // payloadLength = 3 (2 original-size bytes + 1 compressed byte)
for _ in 0..<8 {
malformedData.append(0x01)
}
malformedData.append(0xFF)
malformedData.append(0xFF) // originalSize = 65535
malformedData.append(0x99) // compressed payload length = 1 => ratio > 50_000
#expect(BinaryProtocol.decode(malformedData) == nil)
}
@Test("Test packet designed to cause integer overflow")
func maliciousPacketWithIntegerOverflow() throws {
var maliciousData = Data()
// Valid header
maliciousData.append(1) // version
maliciousData.append(1) // type
maliciousData.append(10) // ttl
// Timestamp (8 bytes)
for _ in 0..<8 {
maliciousData.append(0)
}
// Set flags to have recipient and signature (increase expected size)
maliciousData.append(0x03) // hasRecipient | hasSignature
// Very large payload length
maliciousData.append(0xFF) // high byte
maliciousData.append(0xFE) // low byte (65534)
// SenderID (8 bytes)
for _ in 0..<8 {
maliciousData.append(0x01)
}
// RecipientID (8 bytes - required due to flag)
for _ in 0..<8 {
maliciousData.append(0x02)
}
// Provide minimal payload data - should trigger bounds check failure
maliciousData.append(contentsOf: [0x01, 0x02])
// Should handle gracefully without integer overflow issues
let result = BinaryProtocol.decode(maliciousData)
#expect(result == nil, "Malicious packet designed for integer overflow should return nil, not crash")
}
@Test("Test packets with incomplete headers")
func partialHeaderData() throws {
let headerSizes = [0, 1, 5, 10, 12] // Various incomplete header sizes
for size in headerSizes {
let partialData = Data(repeating: 0x01, count: size)
let result = BinaryProtocol.decode(partialData)
#expect(result == nil, "Partial header data (\(size) bytes) should return nil, not crash")
}
}
@Test("Test exact boundary conditions")
func boundaryConditions() throws {
let packet = TestHelpers.createTestPacket()
let validEncoded = try #require(BinaryProtocol.encode(packet), "Failed to encode test packet")
// If truncation only removes padding, decode may still succeed. Compute unpadded size.
let unpadded = MessagePadding.unpad(validEncoded)
// Truncate within the unpadded frame to guarantee corruption
let cut = max(1, unpadded.count - 10)
let truncatedCore = unpadded.prefix(cut)
let result = BinaryProtocol.decode(truncatedCore)
#expect(result == nil, "Truncated core frame should return nil, not crash")
// Test minimum valid size - create a valid minimal packet
var minData = Data()
minData.append(1) // version
minData.append(1) // type
minData.append(10) // ttl
// Timestamp (8 bytes)
for _ in 0..<8 {
minData.append(0)
}
minData.append(0) // flags (no optional fields)
minData.append(0) // payload length high byte
minData.append(0) // payload length low byte (0 payload)
// SenderID (8 bytes)
for _ in 0..<8 {
minData.append(0x01)
}
// This should be exactly the minimum size and should decode without crashing
_ = BinaryProtocol.decode(minData)
// The important thing is no crash occurs - result might be nil or valid
// 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
}
}
@@ -0,0 +1,155 @@
//
// KeychainErrorHandlingTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
// BCH-01-009: Tests for proper keychain error classification and handling
import Testing
import Foundation
import BitFoundation
struct KeychainErrorHandlingTests {
// MARK: - Error Classification Tests
@Test func keychainReadResult_successIsNotRecoverable() throws {
let result = KeychainReadResult.success(Data([1, 2, 3]))
#expect(result.isRecoverableError == false)
}
@Test func keychainReadResult_itemNotFoundIsNotRecoverable() throws {
let result = KeychainReadResult.itemNotFound
#expect(result.isRecoverableError == false)
}
@Test func keychainReadResult_deviceLockedIsRecoverable() throws {
let result = KeychainReadResult.deviceLocked
#expect(result.isRecoverableError == true)
}
@Test func keychainReadResult_authenticationFailedIsRecoverable() throws {
let result = KeychainReadResult.authenticationFailed
#expect(result.isRecoverableError == true)
}
@Test func keychainReadResult_accessDeniedIsNotRecoverable() throws {
let result = KeychainReadResult.accessDenied
#expect(result.isRecoverableError == false)
}
@Test func keychainSaveResult_successIsNotRecoverable() throws {
let result = KeychainSaveResult.success
#expect(result.isRecoverableError == false)
}
@Test func keychainSaveResult_duplicateItemIsRecoverable() throws {
let result = KeychainSaveResult.duplicateItem
#expect(result.isRecoverableError == true)
}
@Test func keychainSaveResult_deviceLockedIsRecoverable() throws {
let result = KeychainSaveResult.deviceLocked
#expect(result.isRecoverableError == true)
}
@Test func keychainSaveResult_storageFullIsNotRecoverable() throws {
let result = KeychainSaveResult.storageFull
#expect(result.isRecoverableError == false)
}
// MARK: - Mock Keychain Error Simulation Tests
@Test func mockKeychain_canSimulateReadErrors() throws {
let keychain = MockKeychain()
// Simulate access denied error
keychain.simulatedReadError = .accessDenied
let result = keychain.getIdentityKeyWithResult(forKey: "testKey")
switch result {
case .accessDenied:
// Expected
break
default:
throw KeychainTestError("Expected accessDenied, got \(result)")
}
}
@Test func mockKeychain_canSimulateSaveErrors() throws {
let keychain = MockKeychain()
// Simulate storage full error
keychain.simulatedSaveError = .storageFull
let result = keychain.saveIdentityKeyWithResult(Data([1, 2, 3]), forKey: "testKey")
switch result {
case .storageFull:
// Expected
break
default:
throw KeychainTestError("Expected storageFull, got \(result)")
}
}
@Test func mockKeychain_returnsItemNotFoundForMissingKey() throws {
let keychain = MockKeychain()
let result = keychain.getIdentityKeyWithResult(forKey: "nonExistentKey")
switch result {
case .itemNotFound:
// Expected
break
default:
throw KeychainTestError("Expected itemNotFound, got \(result)")
}
}
@Test func mockKeychain_returnsSuccessForExistingKey() throws {
let keychain = MockKeychain()
let testData = Data([1, 2, 3, 4, 5])
// First save the key
_ = keychain.saveIdentityKey(testData, forKey: "existingKey")
// Now read it back
let result = keychain.getIdentityKeyWithResult(forKey: "existingKey")
switch result {
case .success(let data):
#expect(data == testData)
default:
throw KeychainTestError("Expected success, got \(result)")
}
}
@Test func mockKeychain_saveWithResultStoresData() throws {
let keychain = MockKeychain()
let testData = Data([10, 20, 30])
let saveResult = keychain.saveIdentityKeyWithResult(testData, forKey: "newKey")
switch saveResult {
case .success:
// Verify data was stored
let readResult = keychain.getIdentityKeyWithResult(forKey: "newKey")
switch readResult {
case .success(let data):
#expect(data == testData)
default:
throw KeychainTestError("Expected to read back saved data")
}
default:
throw KeychainTestError("Expected save success, got \(saveResult)")
}
}
}
// Helper error type for tests
private struct KeychainTestError: Error, CustomStringConvertible {
let message: String
init(_ message: String) { self.message = message }
var description: String { message }
}
@@ -0,0 +1,201 @@
//
// MockKeychain.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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)
}
}
@@ -0,0 +1,28 @@
//
// TestConstants.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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)
}
@@ -0,0 +1,143 @@
//
// TestHelpers.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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<T>(
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))
}