mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 14:05:18 +00:00
* Remove dead code and artifacts: drop PeerManager, unused views/types; delete LegacyTestProtocolTypes; update .gitignore; purge TestResult.xcresult and build.log * Tests: gate verbose prints under DEBUG; ChatViewModel: remove legacy fingerprint helper and rely on UnifiedPeerService * Share Extension: migrate to UIKit + UTTypes; drop Social/SLComposeServiceViewController * Remove 'preparing to share …' system message; send shared content immediately * Inline comment cleanup: drop legacy 'removed' breadcrumbs across protocols, services, view model, and views --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com>
532 lines
19 KiB
Swift
532 lines
19 KiB
Swift
//
|
|
// BitchatProtocol.swift
|
|
// bitchat
|
|
//
|
|
// This is free and unencumbered software released into the public domain.
|
|
// For more information, see <https://unlicense.org>
|
|
//
|
|
|
|
///
|
|
/// # BitchatProtocol
|
|
///
|
|
/// Defines the application-layer protocol for BitChat mesh networking, including
|
|
/// message types, packet structures, and encoding/decoding logic.
|
|
///
|
|
/// ## Overview
|
|
/// BitchatProtocol implements a binary protocol optimized for Bluetooth LE's
|
|
/// constrained bandwidth and MTU limitations. It provides:
|
|
/// - Efficient binary message encoding
|
|
/// - Message fragmentation for large payloads
|
|
/// - TTL-based routing for mesh networks
|
|
/// - Privacy features like padding and timing obfuscation
|
|
/// - Integration points for end-to-end encryption
|
|
///
|
|
/// ## Protocol Design
|
|
/// The protocol uses a compact binary format to minimize overhead:
|
|
/// - 1-byte message type identifier
|
|
/// - Variable-length fields with length prefixes
|
|
/// - Network byte order (big-endian) for multi-byte values
|
|
/// - PKCS#7-style padding for privacy
|
|
///
|
|
/// ## Message Flow
|
|
/// 1. **Creation**: Messages are created with type, content, and metadata
|
|
/// 2. **Encoding**: Converted to binary format with proper field ordering
|
|
/// 3. **Fragmentation**: Split if larger than BLE MTU (512 bytes)
|
|
/// 4. **Transmission**: Sent via BLEService
|
|
/// 5. **Routing**: Relayed by intermediate nodes (TTL decrements)
|
|
/// 6. **Reassembly**: Fragments collected and reassembled
|
|
/// 7. **Decoding**: Binary data parsed back to message objects
|
|
///
|
|
/// ## Security Considerations
|
|
/// - Message padding obscures actual content length
|
|
/// - Timing obfuscation prevents traffic analysis
|
|
/// - Integration with Noise Protocol for E2E encryption
|
|
/// - No persistent identifiers in protocol headers
|
|
///
|
|
/// ## Message Types
|
|
/// - **Announce/Leave**: Peer presence notifications
|
|
/// - **Message**: User chat messages (broadcast or directed)
|
|
/// - **Fragment**: Multi-part message handling
|
|
/// - **Delivery/Read**: Message acknowledgments
|
|
/// - **Noise**: Encrypted channel establishment
|
|
/// - **Version**: Protocol version negotiation
|
|
///
|
|
/// ## Future Extensions
|
|
/// The protocol is designed to be extensible:
|
|
/// - Reserved message type ranges for future use
|
|
/// - Version field for protocol evolution
|
|
/// - Optional fields for new features
|
|
///
|
|
|
|
import Foundation
|
|
import CryptoKit
|
|
|
|
// MARK: - Message Padding
|
|
|
|
/// Provides privacy-preserving message padding to obscure actual content length.
|
|
/// Uses PKCS#7-style padding with random bytes to prevent traffic analysis.
|
|
struct MessagePadding {
|
|
// Standard block sizes for padding
|
|
static let blockSizes = [256, 512, 1024, 2048]
|
|
|
|
// Add PKCS#7-style padding to reach target size
|
|
static func pad(_ data: Data, toSize targetSize: Int) -> Data {
|
|
guard data.count < targetSize else { return data }
|
|
|
|
let paddingNeeded = targetSize - data.count
|
|
// Constrain to 255 to fit a single-byte pad length marker
|
|
guard paddingNeeded > 0 && paddingNeeded <= 255 else { return data }
|
|
|
|
var padded = data
|
|
// PKCS#7: All pad bytes are equal to the pad length
|
|
padded.append(contentsOf: Array(repeating: UInt8(paddingNeeded), count: paddingNeeded))
|
|
return padded
|
|
}
|
|
|
|
// Remove padding from data
|
|
static func unpad(_ data: Data) -> Data {
|
|
guard !data.isEmpty else { return data }
|
|
let last = data.last!
|
|
let paddingLength = Int(last)
|
|
// Must have at least 1 pad byte and not exceed data length
|
|
guard paddingLength > 0 && paddingLength <= data.count else { return data }
|
|
// Verify PKCS#7: all last N bytes equal to pad length
|
|
let start = data.count - paddingLength
|
|
let tail = data[start...]
|
|
for b in tail { if b != last { return data } }
|
|
return Data(data[..<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
|
|
}
|
|
}
|
|
|
|
// MARK: - Message Types
|
|
|
|
/// Simplified BitChat protocol message types.
|
|
/// Reduced from 24 types to just 6 essential ones.
|
|
/// All private communication metadata (receipts, status) is embedded in noiseEncrypted payloads.
|
|
enum MessageType: UInt8 {
|
|
// Public messages (unencrypted)
|
|
case announce = 0x01 // "I'm here" with nickname
|
|
case message = 0x02 // Public chat message
|
|
case leave = 0x03 // "I'm leaving"
|
|
|
|
// 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
|
|
|
|
var description: String {
|
|
switch self {
|
|
case .announce: return "announce"
|
|
case .message: return "message"
|
|
case .leave: return "leave"
|
|
case .noiseHandshake: return "noiseHandshake"
|
|
case .noiseEncrypted: return "noiseEncrypted"
|
|
case .fragment: return "fragment"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Noise Payload Types
|
|
|
|
/// Types of payloads embedded within noiseEncrypted messages.
|
|
/// The first byte of decrypted Noise payload indicates the type.
|
|
/// This provides privacy - observers can't distinguish message types.
|
|
enum NoisePayloadType: UInt8 {
|
|
// Messages and status
|
|
case privateMessage = 0x01 // Private chat message
|
|
case readReceipt = 0x02 // Message was read
|
|
case delivered = 0x03 // Message was delivered
|
|
// Verification (QR-based OOB binding)
|
|
case verifyChallenge = 0x10 // Verification challenge
|
|
case verifyResponse = 0x11 // Verification response
|
|
|
|
var description: String {
|
|
switch self {
|
|
case .privateMessage: return "privateMessage"
|
|
case .readReceipt: return "readReceipt"
|
|
case .delivered: return "delivered"
|
|
case .verifyChallenge: return "verifyChallenge"
|
|
case .verifyResponse: return "verifyResponse"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Handshake State
|
|
|
|
// Lazy handshake state tracking
|
|
enum LazyHandshakeState {
|
|
case none // No session, no handshake attempted
|
|
case handshakeQueued // User action requires handshake
|
|
case handshaking // Currently in handshake process
|
|
case established // Session ready for use
|
|
case failed(Error) // Handshake failed
|
|
}
|
|
|
|
//
|
|
|
|
// MARK: - Core Protocol Structures
|
|
|
|
/// The core packet structure for all BitChat protocol messages.
|
|
/// Encapsulates all data needed for routing through the mesh network,
|
|
/// including TTL for hop limiting and optional encryption.
|
|
/// - Note: Packets larger than BLE MTU (512 bytes) are automatically fragmented
|
|
struct BitchatPacket: Codable {
|
|
let version: UInt8
|
|
let type: UInt8
|
|
let senderID: Data
|
|
let recipientID: Data?
|
|
let timestamp: UInt64
|
|
let payload: Data
|
|
var signature: Data?
|
|
var ttl: UInt8
|
|
|
|
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8) {
|
|
self.version = 1
|
|
self.type = type
|
|
self.senderID = senderID
|
|
self.recipientID = recipientID
|
|
self.timestamp = timestamp
|
|
self.payload = payload
|
|
self.signature = signature
|
|
self.ttl = ttl
|
|
}
|
|
|
|
// Convenience initializer for new binary format
|
|
init(type: UInt8, ttl: UInt8, senderID: String, payload: Data) {
|
|
self.version = 1
|
|
self.type = type
|
|
// Convert hex string peer ID to binary data (8 bytes)
|
|
var senderData = Data()
|
|
var tempID = senderID
|
|
while tempID.count >= 2 {
|
|
let hexByte = String(tempID.prefix(2))
|
|
if let byte = UInt8(hexByte, radix: 16) {
|
|
senderData.append(byte)
|
|
}
|
|
tempID = String(tempID.dropFirst(2))
|
|
}
|
|
self.senderID = senderData
|
|
self.recipientID = nil
|
|
self.timestamp = UInt64(Date().timeIntervalSince1970 * 1000) // milliseconds
|
|
self.payload = payload
|
|
self.signature = nil
|
|
self.ttl = ttl
|
|
}
|
|
|
|
var data: Data? {
|
|
BinaryProtocol.encode(self)
|
|
}
|
|
|
|
func toBinaryData(padding: Bool = true) -> Data? {
|
|
BinaryProtocol.encode(self, padding: padding)
|
|
}
|
|
|
|
// Backward-compatible helper (defaults to padded encoding)
|
|
func toBinaryData() -> Data? {
|
|
toBinaryData(padding: true)
|
|
}
|
|
|
|
/// Create binary representation for signing (without signature and TTL fields)
|
|
/// TTL is excluded because it changes during packet relay operations
|
|
func toBinaryDataForSigning() -> Data? {
|
|
// Create a copy without signature and with fixed TTL for signing
|
|
// TTL must be excluded because it changes during relay
|
|
let unsignedPacket = BitchatPacket(
|
|
type: type,
|
|
senderID: senderID,
|
|
recipientID: recipientID,
|
|
timestamp: timestamp,
|
|
payload: payload,
|
|
signature: nil, // Remove signature for signing
|
|
ttl: 0 // Use fixed TTL=0 for signing to ensure relay compatibility
|
|
)
|
|
return BinaryProtocol.encode(unsignedPacket)
|
|
}
|
|
|
|
static func from(_ data: Data) -> BitchatPacket? {
|
|
BinaryProtocol.decode(data)
|
|
}
|
|
}
|
|
|
|
//
|
|
|
|
// MARK: - Read Receipts
|
|
|
|
// Read receipt structure
|
|
struct ReadReceipt: Codable {
|
|
let originalMessageID: String
|
|
let receiptID: String
|
|
var readerID: String // Who read it
|
|
let readerNickname: String
|
|
let timestamp: Date
|
|
|
|
init(originalMessageID: String, readerID: String, readerNickname: String) {
|
|
self.originalMessageID = originalMessageID
|
|
self.receiptID = UUID().uuidString
|
|
self.readerID = readerID
|
|
self.readerNickname = readerNickname
|
|
self.timestamp = Date()
|
|
}
|
|
|
|
// For binary decoding
|
|
private init(originalMessageID: String, receiptID: String, readerID: String, readerNickname: String, timestamp: Date) {
|
|
self.originalMessageID = originalMessageID
|
|
self.receiptID = receiptID
|
|
self.readerID = readerID
|
|
self.readerNickname = readerNickname
|
|
self.timestamp = timestamp
|
|
}
|
|
|
|
func encode() -> Data? {
|
|
try? JSONEncoder().encode(self)
|
|
}
|
|
|
|
static func decode(from data: Data) -> ReadReceipt? {
|
|
try? JSONDecoder().decode(ReadReceipt.self, from: data)
|
|
}
|
|
|
|
// MARK: - Binary Encoding
|
|
|
|
func toBinaryData() -> Data {
|
|
var data = Data()
|
|
data.appendUUID(originalMessageID)
|
|
data.appendUUID(receiptID)
|
|
// ReaderID as 8-byte hex string
|
|
var readerData = Data()
|
|
var tempID = readerID
|
|
while tempID.count >= 2 && readerData.count < 8 {
|
|
let hexByte = String(tempID.prefix(2))
|
|
if let byte = UInt8(hexByte, radix: 16) {
|
|
readerData.append(byte)
|
|
}
|
|
tempID = String(tempID.dropFirst(2))
|
|
}
|
|
while readerData.count < 8 {
|
|
readerData.append(0)
|
|
}
|
|
data.append(readerData)
|
|
data.appendDate(timestamp)
|
|
data.appendString(readerNickname)
|
|
return data
|
|
}
|
|
|
|
static func fromBinaryData(_ data: Data) -> ReadReceipt? {
|
|
// Create defensive copy
|
|
let dataCopy = Data(data)
|
|
|
|
// Minimum size: 2 UUIDs (32) + readerID (8) + timestamp (8) + min nickname
|
|
guard dataCopy.count >= 49 else { return nil }
|
|
|
|
var offset = 0
|
|
|
|
guard let originalMessageID = dataCopy.readUUID(at: &offset),
|
|
let receiptID = dataCopy.readUUID(at: &offset) else { return nil }
|
|
|
|
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
|
|
let readerID = readerIDData.hexEncodedString()
|
|
guard InputValidator.validatePeerID(readerID) else { return nil }
|
|
|
|
guard let timestamp = dataCopy.readDate(at: &offset),
|
|
InputValidator.validateTimestamp(timestamp),
|
|
let readerNicknameRaw = dataCopy.readString(at: &offset),
|
|
let readerNickname = InputValidator.validateNickname(readerNicknameRaw) else { return nil }
|
|
|
|
return ReadReceipt(originalMessageID: originalMessageID,
|
|
receiptID: receiptID,
|
|
readerID: readerID,
|
|
readerNickname: readerNickname,
|
|
timestamp: timestamp)
|
|
}
|
|
}
|
|
|
|
|
|
//
|
|
|
|
|
|
// MARK: - Delivery Status
|
|
|
|
// Delivery status for messages
|
|
enum DeliveryStatus: Codable, Equatable {
|
|
case sending
|
|
case sent // Left our device
|
|
case delivered(to: String, at: Date) // Confirmed by recipient
|
|
case read(by: String, at: Date) // Seen by recipient
|
|
case failed(reason: String)
|
|
case partiallyDelivered(reached: Int, total: Int) // For rooms
|
|
|
|
var displayText: String {
|
|
switch self {
|
|
case .sending:
|
|
return "Sending..."
|
|
case .sent:
|
|
return "Sent"
|
|
case .delivered(let nickname, _):
|
|
return "Delivered to \(nickname)"
|
|
case .read(let nickname, _):
|
|
return "Read by \(nickname)"
|
|
case .failed(let reason):
|
|
return "Failed: \(reason)"
|
|
case .partiallyDelivered(let reached, let total):
|
|
return "Delivered to \(reached)/\(total)"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Message Model
|
|
|
|
/// 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
|
|
class BitchatMessage: Codable {
|
|
let id: String
|
|
let sender: String
|
|
let content: String
|
|
let timestamp: Date
|
|
let isRelay: Bool
|
|
let originalSender: String?
|
|
let isPrivate: Bool
|
|
let recipientNickname: String?
|
|
let senderPeerID: String?
|
|
let mentions: [String]? // Array of mentioned nicknames
|
|
var deliveryStatus: DeliveryStatus? // Delivery tracking
|
|
|
|
// Cached formatted text (not included in Codable)
|
|
private var _cachedFormattedText: [String: AttributedString] = [:]
|
|
|
|
func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? {
|
|
return _cachedFormattedText["\(isDark)-\(isSelf)"]
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
init(id: String? = nil, sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = 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)
|
|
}
|
|
}
|
|
|
|
// Equatable conformance for BitchatMessage
|
|
extension BitchatMessage: Equatable {
|
|
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: - Delegate Protocol
|
|
|
|
protocol BitchatDelegate: AnyObject {
|
|
func didReceiveMessage(_ message: BitchatMessage)
|
|
func didConnectToPeer(_ peerID: String)
|
|
func didDisconnectFromPeer(_ peerID: String)
|
|
func didUpdatePeerList(_ peers: [String])
|
|
|
|
// Optional method to check if a fingerprint belongs to a favorite peer
|
|
func isFavorite(fingerprint: String) -> Bool
|
|
|
|
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus)
|
|
|
|
// Low-level events for better separation of concerns
|
|
func didReceiveNoisePayload(from peerID: String, type: NoisePayloadType, payload: Data, timestamp: Date)
|
|
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date)
|
|
}
|
|
|
|
// Provide default implementation to make it effectively optional
|
|
extension BitchatDelegate {
|
|
func isFavorite(fingerprint: String) -> Bool {
|
|
return false
|
|
}
|
|
|
|
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {
|
|
// Default empty implementation
|
|
}
|
|
|
|
func didReceiveNoisePayload(from peerID: String, type: NoisePayloadType, payload: Data, timestamp: Date) {
|
|
// Default empty implementation
|
|
}
|
|
|
|
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) {
|
|
// Default empty implementation
|
|
}
|
|
}
|
|
|
|
// MARK: - Noise Payload Helpers
|
|
|
|
/// Helper to create typed Noise payloads
|
|
struct NoisePayload {
|
|
let type: NoisePayloadType
|
|
let data: Data
|
|
|
|
/// Encode payload with type prefix
|
|
func encode() -> Data {
|
|
var encoded = Data()
|
|
encoded.append(type.rawValue)
|
|
encoded.append(data)
|
|
return encoded
|
|
}
|
|
|
|
/// Decode payload from data
|
|
static func decode(_ data: Data) -> NoisePayload? {
|
|
// Ensure we have at least 1 byte for the type
|
|
guard !data.isEmpty else {
|
|
return nil
|
|
}
|
|
|
|
// Safely get the first byte
|
|
let firstByte = data[data.startIndex]
|
|
guard let type = NoisePayloadType(rawValue: firstByte) else {
|
|
return nil
|
|
}
|
|
|
|
// Create a proper Data copy (not a subsequence) for thread safety
|
|
let payloadData = data.count > 1 ? Data(data.dropFirst()) : Data()
|
|
return NoisePayload(type: type, data: payloadData)
|
|
}
|
|
}
|