mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 02:05:19 +00:00
Move additional files/tests to BitFoundation (#1102)
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user