mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 13:05:18 +00:00
- Convert BitchatMessage from struct to class for efficient caching - Cache formatted AttributedStrings to avoid expensive regex on every render - Replace ScrollView+LazyVStack with native List for better cell reuse - Implement message windowing (show last 100 messages for performance) - Add LazyLinkPreviewView that defers loading for 0.5s to improve scroll performance These optimizations significantly improve scroll performance, especially with large message lists.
1034 lines
37 KiB
Swift
1034 lines
37 KiB
Swift
//
|
|
// BitchatProtocol.swift
|
|
// bitchat
|
|
//
|
|
// This is free and unencumbered software released into the public domain.
|
|
// For more information, see <https://unlicense.org>
|
|
//
|
|
|
|
import Foundation
|
|
import CryptoKit
|
|
|
|
// Privacy-preserving padding utilities
|
|
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
|
|
|
|
// PKCS#7 only supports padding up to 255 bytes
|
|
// If we need more padding than that, don't pad - return original data
|
|
guard paddingNeeded <= 255 else { return data }
|
|
|
|
var padded = data
|
|
|
|
// Standard PKCS#7 padding
|
|
var randomBytes = [UInt8](repeating: 0, count: paddingNeeded - 1)
|
|
_ = SecRandomCopyBytes(kSecRandomDefault, paddingNeeded - 1, &randomBytes)
|
|
padded.append(contentsOf: randomBytes)
|
|
padded.append(UInt8(paddingNeeded))
|
|
|
|
return padded
|
|
}
|
|
|
|
// Remove padding from data
|
|
static func unpad(_ data: Data) -> Data {
|
|
guard !data.isEmpty else { return data }
|
|
|
|
// Last byte tells us how much padding to remove
|
|
let paddingLength = Int(data[data.count - 1])
|
|
guard paddingLength > 0 && paddingLength <= data.count else {
|
|
// Debug logging for 243-byte packets
|
|
if data.count == 243 {
|
|
}
|
|
return data
|
|
}
|
|
|
|
let result = data.prefix(data.count - paddingLength)
|
|
|
|
// Debug logging for 243-byte packets
|
|
if data.count == 243 {
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
enum MessageType: UInt8 {
|
|
case announce = 0x01
|
|
case leave = 0x03
|
|
case message = 0x04 // All user messages (private and broadcast)
|
|
case fragmentStart = 0x05
|
|
case fragmentContinue = 0x06
|
|
case fragmentEnd = 0x07
|
|
case deliveryAck = 0x0A // Acknowledge message received
|
|
case deliveryStatusRequest = 0x0B // Request delivery status update
|
|
case readReceipt = 0x0C // Message has been read/viewed
|
|
|
|
// Noise Protocol messages
|
|
case noiseHandshakeInit = 0x10 // Noise handshake initiation
|
|
case noiseHandshakeResp = 0x11 // Noise handshake response
|
|
case noiseEncrypted = 0x12 // Noise encrypted transport message
|
|
case noiseIdentityAnnounce = 0x13 // Announce static public key for discovery
|
|
|
|
// Protocol version negotiation
|
|
case versionHello = 0x20 // Initial version announcement
|
|
case versionAck = 0x21 // Version acknowledgment
|
|
|
|
// Protocol-level acknowledgments
|
|
case protocolAck = 0x22 // Generic protocol acknowledgment
|
|
case protocolNack = 0x23 // Negative acknowledgment (failure)
|
|
case systemValidation = 0x24 // Session validation ping
|
|
|
|
var description: String {
|
|
switch self {
|
|
case .announce: return "announce"
|
|
case .leave: return "leave"
|
|
case .message: return "message"
|
|
case .fragmentStart: return "fragmentStart"
|
|
case .fragmentContinue: return "fragmentContinue"
|
|
case .fragmentEnd: return "fragmentEnd"
|
|
case .deliveryAck: return "deliveryAck"
|
|
case .deliveryStatusRequest: return "deliveryStatusRequest"
|
|
case .readReceipt: return "readReceipt"
|
|
case .noiseHandshakeInit: return "noiseHandshakeInit"
|
|
case .noiseHandshakeResp: return "noiseHandshakeResp"
|
|
case .noiseEncrypted: return "noiseEncrypted"
|
|
case .noiseIdentityAnnounce: return "noiseIdentityAnnounce"
|
|
case .versionHello: return "versionHello"
|
|
case .versionAck: return "versionAck"
|
|
case .protocolAck: return "protocolAck"
|
|
case .protocolNack: return "protocolNack"
|
|
case .systemValidation: return "systemValidation"
|
|
}
|
|
}
|
|
}
|
|
|
|
// Special recipient ID for broadcast messages
|
|
struct SpecialRecipients {
|
|
static let broadcast = Data(repeating: 0xFF, count: 8) // All 0xFF = broadcast
|
|
}
|
|
|
|
struct BitchatPacket: Codable {
|
|
let version: UInt8
|
|
let type: UInt8
|
|
let senderID: Data
|
|
let recipientID: Data?
|
|
let timestamp: UInt64
|
|
let payload: Data
|
|
let 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() -> Data? {
|
|
BinaryProtocol.encode(self)
|
|
}
|
|
|
|
static func from(_ data: Data) -> BitchatPacket? {
|
|
BinaryProtocol.decode(data)
|
|
}
|
|
}
|
|
|
|
// Delivery acknowledgment structure
|
|
struct DeliveryAck: Codable {
|
|
let originalMessageID: String
|
|
let ackID: String
|
|
let recipientID: String // Who received it
|
|
let recipientNickname: String
|
|
let timestamp: Date
|
|
let hopCount: UInt8 // How many hops to reach recipient
|
|
|
|
init(originalMessageID: String, recipientID: String, recipientNickname: String, hopCount: UInt8) {
|
|
self.originalMessageID = originalMessageID
|
|
self.ackID = UUID().uuidString
|
|
self.recipientID = recipientID
|
|
self.recipientNickname = recipientNickname
|
|
self.timestamp = Date()
|
|
self.hopCount = hopCount
|
|
}
|
|
|
|
// For binary decoding
|
|
private init(originalMessageID: String, ackID: String, recipientID: String, recipientNickname: String, timestamp: Date, hopCount: UInt8) {
|
|
self.originalMessageID = originalMessageID
|
|
self.ackID = ackID
|
|
self.recipientID = recipientID
|
|
self.recipientNickname = recipientNickname
|
|
self.timestamp = timestamp
|
|
self.hopCount = hopCount
|
|
}
|
|
|
|
func encode() -> Data? {
|
|
try? JSONEncoder().encode(self)
|
|
}
|
|
|
|
static func decode(from data: Data) -> DeliveryAck? {
|
|
try? JSONDecoder().decode(DeliveryAck.self, from: data)
|
|
}
|
|
|
|
// MARK: - Binary Encoding
|
|
|
|
func toBinaryData() -> Data {
|
|
var data = Data()
|
|
data.appendUUID(originalMessageID)
|
|
data.appendUUID(ackID)
|
|
// RecipientID as 8-byte hex string
|
|
var recipientData = Data()
|
|
var tempID = recipientID
|
|
while tempID.count >= 2 && recipientData.count < 8 {
|
|
let hexByte = String(tempID.prefix(2))
|
|
if let byte = UInt8(hexByte, radix: 16) {
|
|
recipientData.append(byte)
|
|
}
|
|
tempID = String(tempID.dropFirst(2))
|
|
}
|
|
while recipientData.count < 8 {
|
|
recipientData.append(0)
|
|
}
|
|
data.append(recipientData)
|
|
data.appendUInt8(hopCount)
|
|
data.appendDate(timestamp)
|
|
data.appendString(recipientNickname)
|
|
return data
|
|
}
|
|
|
|
static func fromBinaryData(_ data: Data) -> DeliveryAck? {
|
|
// Create defensive copy
|
|
let dataCopy = Data(data)
|
|
|
|
// Minimum size: 2 UUIDs (32) + recipientID (8) + hopCount (1) + timestamp (8) + min nickname
|
|
guard dataCopy.count >= 50 else { return nil }
|
|
|
|
var offset = 0
|
|
|
|
guard let originalMessageID = dataCopy.readUUID(at: &offset),
|
|
let ackID = dataCopy.readUUID(at: &offset) else { return nil }
|
|
|
|
guard let recipientIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
|
|
let recipientID = recipientIDData.hexEncodedString()
|
|
|
|
guard let hopCount = dataCopy.readUInt8(at: &offset),
|
|
let timestamp = dataCopy.readDate(at: &offset),
|
|
let recipientNickname = dataCopy.readString(at: &offset) else { return nil }
|
|
|
|
return DeliveryAck(originalMessageID: originalMessageID,
|
|
ackID: ackID,
|
|
recipientID: recipientID,
|
|
recipientNickname: recipientNickname,
|
|
timestamp: timestamp,
|
|
hopCount: hopCount)
|
|
}
|
|
}
|
|
|
|
// Read receipt structure
|
|
struct ReadReceipt: Codable {
|
|
let originalMessageID: String
|
|
let receiptID: String
|
|
let 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 let timestamp = dataCopy.readDate(at: &offset),
|
|
let readerNickname = dataCopy.readString(at: &offset) else { return nil }
|
|
|
|
return ReadReceipt(originalMessageID: originalMessageID,
|
|
receiptID: receiptID,
|
|
readerID: readerID,
|
|
readerNickname: readerNickname,
|
|
timestamp: timestamp)
|
|
}
|
|
}
|
|
|
|
// MARK: - Protocol Acknowledgments
|
|
|
|
// Protocol-level acknowledgment for reliable delivery
|
|
struct ProtocolAck: Codable {
|
|
let originalPacketID: String // ID of the packet being acknowledged
|
|
let ackID: String // Unique ID for this ACK
|
|
let senderID: String // Who sent the original packet
|
|
let receiverID: String // Who received and is acknowledging
|
|
let packetType: UInt8 // Type of packet being acknowledged
|
|
let timestamp: Date // When ACK was generated
|
|
let hopCount: UInt8 // Hops taken to reach receiver
|
|
|
|
init(originalPacketID: String, senderID: String, receiverID: String, packetType: UInt8, hopCount: UInt8) {
|
|
self.originalPacketID = originalPacketID
|
|
self.ackID = UUID().uuidString
|
|
self.senderID = senderID
|
|
self.receiverID = receiverID
|
|
self.packetType = packetType
|
|
self.timestamp = Date()
|
|
self.hopCount = hopCount
|
|
}
|
|
|
|
// Private init for binary decoding
|
|
private init(originalPacketID: String, ackID: String, senderID: String, receiverID: String,
|
|
packetType: UInt8, timestamp: Date, hopCount: UInt8) {
|
|
self.originalPacketID = originalPacketID
|
|
self.ackID = ackID
|
|
self.senderID = senderID
|
|
self.receiverID = receiverID
|
|
self.packetType = packetType
|
|
self.timestamp = timestamp
|
|
self.hopCount = hopCount
|
|
}
|
|
|
|
func toBinaryData() -> Data {
|
|
var data = Data()
|
|
data.appendUUID(originalPacketID)
|
|
data.appendUUID(ackID)
|
|
|
|
// Sender and receiver IDs as 8-byte hex strings
|
|
data.append(Data(hexString: senderID) ?? Data(repeating: 0, count: 8))
|
|
data.append(Data(hexString: receiverID) ?? Data(repeating: 0, count: 8))
|
|
|
|
data.appendUInt8(packetType)
|
|
data.appendUInt8(hopCount)
|
|
data.appendDate(timestamp)
|
|
return data
|
|
}
|
|
|
|
static func fromBinaryData(_ data: Data) -> ProtocolAck? {
|
|
let dataCopy = Data(data)
|
|
guard dataCopy.count >= 50 else { return nil } // 2 UUIDs + 2 IDs + type + hop + timestamp
|
|
|
|
var offset = 0
|
|
guard let originalPacketID = dataCopy.readUUID(at: &offset),
|
|
let ackID = dataCopy.readUUID(at: &offset),
|
|
let senderIDData = dataCopy.readFixedBytes(at: &offset, count: 8),
|
|
let receiverIDData = dataCopy.readFixedBytes(at: &offset, count: 8),
|
|
let packetType = dataCopy.readUInt8(at: &offset),
|
|
let hopCount = dataCopy.readUInt8(at: &offset),
|
|
let timestamp = dataCopy.readDate(at: &offset) else { return nil }
|
|
|
|
let senderID = senderIDData.hexEncodedString()
|
|
let receiverID = receiverIDData.hexEncodedString()
|
|
|
|
return ProtocolAck(originalPacketID: originalPacketID,
|
|
ackID: ackID,
|
|
senderID: senderID,
|
|
receiverID: receiverID,
|
|
packetType: packetType,
|
|
timestamp: timestamp,
|
|
hopCount: hopCount)
|
|
}
|
|
}
|
|
|
|
// Protocol-level negative acknowledgment
|
|
struct ProtocolNack: Codable {
|
|
let originalPacketID: String // ID of the packet that failed
|
|
let nackID: String // Unique ID for this NACK
|
|
let senderID: String // Who sent the original packet
|
|
let receiverID: String // Who is reporting the failure
|
|
let packetType: UInt8 // Type of packet that failed
|
|
let timestamp: Date // When NACK was generated
|
|
let reason: String // Reason for failure
|
|
let errorCode: UInt8 // Numeric error code
|
|
|
|
// Error codes
|
|
enum ErrorCode: UInt8 {
|
|
case unknown = 0
|
|
case checksumFailed = 1
|
|
case decryptionFailed = 2
|
|
case malformedPacket = 3
|
|
case unsupportedVersion = 4
|
|
case resourceExhausted = 5
|
|
case routingFailed = 6
|
|
case sessionExpired = 7
|
|
}
|
|
|
|
init(originalPacketID: String, senderID: String, receiverID: String,
|
|
packetType: UInt8, reason: String, errorCode: ErrorCode = .unknown) {
|
|
self.originalPacketID = originalPacketID
|
|
self.nackID = UUID().uuidString
|
|
self.senderID = senderID
|
|
self.receiverID = receiverID
|
|
self.packetType = packetType
|
|
self.timestamp = Date()
|
|
self.reason = reason
|
|
self.errorCode = errorCode.rawValue
|
|
}
|
|
|
|
// Private init for binary decoding
|
|
private init(originalPacketID: String, nackID: String, senderID: String, receiverID: String,
|
|
packetType: UInt8, timestamp: Date, reason: String, errorCode: UInt8) {
|
|
self.originalPacketID = originalPacketID
|
|
self.nackID = nackID
|
|
self.senderID = senderID
|
|
self.receiverID = receiverID
|
|
self.packetType = packetType
|
|
self.timestamp = timestamp
|
|
self.reason = reason
|
|
self.errorCode = errorCode
|
|
}
|
|
|
|
func toBinaryData() -> Data {
|
|
var data = Data()
|
|
data.appendUUID(originalPacketID)
|
|
data.appendUUID(nackID)
|
|
|
|
// Sender and receiver IDs as 8-byte hex strings
|
|
data.append(Data(hexString: senderID) ?? Data(repeating: 0, count: 8))
|
|
data.append(Data(hexString: receiverID) ?? Data(repeating: 0, count: 8))
|
|
|
|
data.appendUInt8(packetType)
|
|
data.appendUInt8(errorCode)
|
|
data.appendDate(timestamp)
|
|
data.appendString(reason)
|
|
return data
|
|
}
|
|
|
|
static func fromBinaryData(_ data: Data) -> ProtocolNack? {
|
|
let dataCopy = Data(data)
|
|
guard dataCopy.count >= 52 else { return nil } // Minimum size
|
|
|
|
var offset = 0
|
|
guard let originalPacketID = dataCopy.readUUID(at: &offset),
|
|
let nackID = dataCopy.readUUID(at: &offset),
|
|
let senderIDData = dataCopy.readFixedBytes(at: &offset, count: 8),
|
|
let receiverIDData = dataCopy.readFixedBytes(at: &offset, count: 8),
|
|
let packetType = dataCopy.readUInt8(at: &offset),
|
|
let errorCode = dataCopy.readUInt8(at: &offset),
|
|
let timestamp = dataCopy.readDate(at: &offset),
|
|
let reason = dataCopy.readString(at: &offset) else { return nil }
|
|
|
|
let senderID = senderIDData.hexEncodedString()
|
|
let receiverID = receiverIDData.hexEncodedString()
|
|
|
|
return ProtocolNack(originalPacketID: originalPacketID,
|
|
nackID: nackID,
|
|
senderID: senderID,
|
|
receiverID: receiverID,
|
|
packetType: packetType,
|
|
timestamp: timestamp,
|
|
reason: reason,
|
|
errorCode: errorCode)
|
|
}
|
|
}
|
|
|
|
// MARK: - Peer Identity Rotation
|
|
|
|
// Enhanced identity announcement with rotation support
|
|
struct NoiseIdentityAnnouncement: Codable {
|
|
let peerID: String // Current ephemeral peer ID
|
|
let publicKey: Data // Noise static public key
|
|
let signingPublicKey: Data // Ed25519 signing public key
|
|
let nickname: String // Current nickname
|
|
let timestamp: Date // When this binding was created
|
|
let previousPeerID: String? // Previous peer ID (for smooth transition)
|
|
let signature: Data // Signature proving ownership
|
|
|
|
init(peerID: String, publicKey: Data, signingPublicKey: Data, nickname: String, timestamp: Date, previousPeerID: String? = nil, signature: Data) {
|
|
self.peerID = peerID
|
|
self.publicKey = publicKey
|
|
self.signingPublicKey = signingPublicKey
|
|
self.nickname = nickname
|
|
self.timestamp = timestamp
|
|
self.previousPeerID = previousPeerID
|
|
self.signature = signature
|
|
}
|
|
|
|
func encode() -> Data? {
|
|
return try? JSONEncoder().encode(self)
|
|
}
|
|
|
|
static func decode(from data: Data) -> NoiseIdentityAnnouncement? {
|
|
return try? JSONDecoder().decode(NoiseIdentityAnnouncement.self, from: data)
|
|
}
|
|
|
|
// MARK: - Binary Encoding
|
|
|
|
func toBinaryData() -> Data {
|
|
var data = Data()
|
|
|
|
// Flags byte: bit 0 = hasPreviousPeerID
|
|
var flags: UInt8 = 0
|
|
if previousPeerID != nil { flags |= 0x01 }
|
|
data.appendUInt8(flags)
|
|
|
|
// PeerID as 8-byte hex string
|
|
var peerData = Data()
|
|
var tempID = peerID
|
|
while tempID.count >= 2 && peerData.count < 8 {
|
|
let hexByte = String(tempID.prefix(2))
|
|
if let byte = UInt8(hexByte, radix: 16) {
|
|
peerData.append(byte)
|
|
}
|
|
tempID = String(tempID.dropFirst(2))
|
|
}
|
|
while peerData.count < 8 {
|
|
peerData.append(0)
|
|
}
|
|
data.append(peerData)
|
|
|
|
data.appendData(publicKey)
|
|
data.appendData(signingPublicKey)
|
|
data.appendString(nickname)
|
|
data.appendDate(timestamp)
|
|
|
|
if let previousPeerID = previousPeerID {
|
|
// Previous PeerID as 8-byte hex string
|
|
var prevData = Data()
|
|
var tempPrevID = previousPeerID
|
|
while tempPrevID.count >= 2 && prevData.count < 8 {
|
|
let hexByte = String(tempPrevID.prefix(2))
|
|
if let byte = UInt8(hexByte, radix: 16) {
|
|
prevData.append(byte)
|
|
}
|
|
tempPrevID = String(tempPrevID.dropFirst(2))
|
|
}
|
|
while prevData.count < 8 {
|
|
prevData.append(0)
|
|
}
|
|
data.append(prevData)
|
|
}
|
|
|
|
data.appendData(signature)
|
|
|
|
return data
|
|
}
|
|
|
|
static func fromBinaryData(_ data: Data) -> NoiseIdentityAnnouncement? {
|
|
// Create defensive copy
|
|
let dataCopy = Data(data)
|
|
|
|
// Minimum size check: flags(1) + peerID(8) + min data lengths
|
|
guard dataCopy.count >= 20 else { return nil }
|
|
|
|
var offset = 0
|
|
|
|
guard let flags = dataCopy.readUInt8(at: &offset) else { return nil }
|
|
let hasPreviousPeerID = (flags & 0x01) != 0
|
|
|
|
// Read peerID using safe method
|
|
guard let peerIDBytes = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
|
|
let peerID = peerIDBytes.hexEncodedString()
|
|
|
|
guard let publicKey = dataCopy.readData(at: &offset),
|
|
let signingPublicKey = dataCopy.readData(at: &offset),
|
|
let nickname = dataCopy.readString(at: &offset),
|
|
let timestamp = dataCopy.readDate(at: &offset) else { return nil }
|
|
|
|
var previousPeerID: String? = nil
|
|
if hasPreviousPeerID {
|
|
// Read previousPeerID using safe method
|
|
guard let prevIDBytes = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
|
|
previousPeerID = prevIDBytes.hexEncodedString()
|
|
}
|
|
|
|
guard let signature = dataCopy.readData(at: &offset) else { return nil }
|
|
|
|
return NoiseIdentityAnnouncement(peerID: peerID,
|
|
publicKey: publicKey,
|
|
signingPublicKey: signingPublicKey,
|
|
nickname: nickname,
|
|
timestamp: timestamp,
|
|
previousPeerID: previousPeerID,
|
|
signature: signature)
|
|
}
|
|
}
|
|
|
|
// Binding between ephemeral peer ID and cryptographic identity
|
|
struct PeerIdentityBinding {
|
|
let currentPeerID: String // Current ephemeral ID
|
|
let fingerprint: String // Permanent cryptographic identity
|
|
let publicKey: Data // Noise static public key
|
|
let signingPublicKey: Data // Ed25519 signing public key
|
|
let nickname: String // Last known nickname
|
|
let bindingTimestamp: Date // When this binding was created
|
|
let signature: Data // Cryptographic proof of binding
|
|
|
|
// Verify the binding signature
|
|
func verify() -> Bool {
|
|
let bindingData = currentPeerID.data(using: .utf8)! + publicKey +
|
|
String(Int64(bindingTimestamp.timeIntervalSince1970 * 1000)).data(using: .utf8)!
|
|
|
|
do {
|
|
let signingKey = try Curve25519.Signing.PublicKey(rawRepresentation: signingPublicKey)
|
|
return signingKey.isValidSignature(signature, for: bindingData)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Protocol Version Negotiation
|
|
|
|
// Protocol version constants
|
|
struct ProtocolVersion {
|
|
static let current: UInt8 = 1
|
|
static let minimum: UInt8 = 1
|
|
static let maximum: UInt8 = 1
|
|
|
|
// Future versions can be added here
|
|
static let supportedVersions: Set<UInt8> = [1]
|
|
|
|
static func isSupported(_ version: UInt8) -> Bool {
|
|
return supportedVersions.contains(version)
|
|
}
|
|
|
|
static func negotiateVersion(clientVersions: [UInt8], serverVersions: [UInt8]) -> UInt8? {
|
|
// Find the highest common version
|
|
let clientSet = Set(clientVersions)
|
|
let serverSet = Set(serverVersions)
|
|
let common = clientSet.intersection(serverSet)
|
|
|
|
return common.max()
|
|
}
|
|
}
|
|
|
|
// Version negotiation hello message
|
|
struct VersionHello: Codable {
|
|
let supportedVersions: [UInt8] // List of supported protocol versions
|
|
let preferredVersion: UInt8 // Preferred version (usually the latest)
|
|
let clientVersion: String // App version string (e.g., "1.0.0")
|
|
let platform: String // Platform identifier (e.g., "iOS", "macOS")
|
|
let capabilities: [String]? // Optional capability flags for future extensions
|
|
|
|
init(supportedVersions: [UInt8] = Array(ProtocolVersion.supportedVersions),
|
|
preferredVersion: UInt8 = ProtocolVersion.current,
|
|
clientVersion: String,
|
|
platform: String,
|
|
capabilities: [String]? = nil) {
|
|
self.supportedVersions = supportedVersions
|
|
self.preferredVersion = preferredVersion
|
|
self.clientVersion = clientVersion
|
|
self.platform = platform
|
|
self.capabilities = capabilities
|
|
}
|
|
|
|
func encode() -> Data? {
|
|
return try? JSONEncoder().encode(self)
|
|
}
|
|
|
|
static func decode(from data: Data) -> VersionHello? {
|
|
try? JSONDecoder().decode(VersionHello.self, from: data)
|
|
}
|
|
|
|
// MARK: - Binary Encoding
|
|
|
|
func toBinaryData() -> Data {
|
|
var data = Data()
|
|
|
|
// Flags byte: bit 0 = hasCapabilities
|
|
var flags: UInt8 = 0
|
|
if capabilities != nil { flags |= 0x01 }
|
|
data.appendUInt8(flags)
|
|
|
|
// Supported versions array
|
|
data.appendUInt8(UInt8(supportedVersions.count))
|
|
for version in supportedVersions {
|
|
data.appendUInt8(version)
|
|
}
|
|
|
|
data.appendUInt8(preferredVersion)
|
|
data.appendString(clientVersion)
|
|
data.appendString(platform)
|
|
|
|
if let capabilities = capabilities {
|
|
data.appendUInt8(UInt8(capabilities.count))
|
|
for capability in capabilities {
|
|
data.appendString(capability)
|
|
}
|
|
}
|
|
|
|
return data
|
|
}
|
|
|
|
static func fromBinaryData(_ data: Data) -> VersionHello? {
|
|
// Create defensive copy
|
|
let dataCopy = Data(data)
|
|
|
|
// Minimum size check: flags(1) + versionCount(1) + at least one version(1) + preferredVersion(1) + min strings
|
|
guard dataCopy.count >= 4 else { return nil }
|
|
|
|
var offset = 0
|
|
|
|
guard let flags = dataCopy.readUInt8(at: &offset) else { return nil }
|
|
let hasCapabilities = (flags & 0x01) != 0
|
|
|
|
guard let versionCount = dataCopy.readUInt8(at: &offset) else { return nil }
|
|
var supportedVersions: [UInt8] = []
|
|
for _ in 0..<versionCount {
|
|
guard let version = dataCopy.readUInt8(at: &offset) else { return nil }
|
|
supportedVersions.append(version)
|
|
}
|
|
|
|
guard let preferredVersion = dataCopy.readUInt8(at: &offset),
|
|
let clientVersion = dataCopy.readString(at: &offset),
|
|
let platform = dataCopy.readString(at: &offset) else { return nil }
|
|
|
|
var capabilities: [String]? = nil
|
|
if hasCapabilities {
|
|
guard let capCount = dataCopy.readUInt8(at: &offset) else { return nil }
|
|
capabilities = []
|
|
for _ in 0..<capCount {
|
|
guard let capability = dataCopy.readString(at: &offset) else { return nil }
|
|
capabilities?.append(capability)
|
|
}
|
|
}
|
|
|
|
return VersionHello(supportedVersions: supportedVersions,
|
|
preferredVersion: preferredVersion,
|
|
clientVersion: clientVersion,
|
|
platform: platform,
|
|
capabilities: capabilities)
|
|
}
|
|
}
|
|
|
|
// Version negotiation acknowledgment
|
|
struct VersionAck: Codable {
|
|
let agreedVersion: UInt8 // The version both peers will use
|
|
let serverVersion: String // Responder's app version
|
|
let platform: String // Responder's platform
|
|
let capabilities: [String]? // Responder's capabilities
|
|
let rejected: Bool // True if no compatible version found
|
|
let reason: String? // Reason for rejection if applicable
|
|
|
|
init(agreedVersion: UInt8,
|
|
serverVersion: String,
|
|
platform: String,
|
|
capabilities: [String]? = nil,
|
|
rejected: Bool = false,
|
|
reason: String? = nil) {
|
|
self.agreedVersion = agreedVersion
|
|
self.serverVersion = serverVersion
|
|
self.platform = platform
|
|
self.capabilities = capabilities
|
|
self.rejected = rejected
|
|
self.reason = reason
|
|
}
|
|
|
|
func encode() -> Data? {
|
|
return try? JSONEncoder().encode(self)
|
|
}
|
|
|
|
static func decode(from data: Data) -> VersionAck? {
|
|
try? JSONDecoder().decode(VersionAck.self, from: data)
|
|
}
|
|
|
|
// MARK: - Binary Encoding
|
|
|
|
func toBinaryData() -> Data {
|
|
var data = Data()
|
|
|
|
// Flags byte: bit 0 = hasCapabilities, bit 1 = hasReason
|
|
var flags: UInt8 = 0
|
|
if capabilities != nil { flags |= 0x01 }
|
|
if reason != nil { flags |= 0x02 }
|
|
data.appendUInt8(flags)
|
|
|
|
data.appendUInt8(agreedVersion)
|
|
data.appendString(serverVersion)
|
|
data.appendString(platform)
|
|
data.appendUInt8(rejected ? 1 : 0)
|
|
|
|
if let capabilities = capabilities {
|
|
data.appendUInt8(UInt8(capabilities.count))
|
|
for capability in capabilities {
|
|
data.appendString(capability)
|
|
}
|
|
}
|
|
|
|
if let reason = reason {
|
|
data.appendString(reason)
|
|
}
|
|
|
|
return data
|
|
}
|
|
|
|
static func fromBinaryData(_ data: Data) -> VersionAck? {
|
|
// Create defensive copy
|
|
let dataCopy = Data(data)
|
|
|
|
// Minimum size: flags(1) + version(1) + rejected(1) + min strings
|
|
guard dataCopy.count >= 5 else { return nil }
|
|
|
|
var offset = 0
|
|
|
|
guard let flags = dataCopy.readUInt8(at: &offset) else { return nil }
|
|
let hasCapabilities = (flags & 0x01) != 0
|
|
let hasReason = (flags & 0x02) != 0
|
|
|
|
guard let agreedVersion = dataCopy.readUInt8(at: &offset),
|
|
let serverVersion = dataCopy.readString(at: &offset),
|
|
let platform = dataCopy.readString(at: &offset),
|
|
let rejectedByte = dataCopy.readUInt8(at: &offset) else { return nil }
|
|
|
|
let rejected = rejectedByte != 0
|
|
|
|
var capabilities: [String]? = nil
|
|
if hasCapabilities {
|
|
guard let capCount = dataCopy.readUInt8(at: &offset) else { return nil }
|
|
capabilities = []
|
|
for _ in 0..<capCount {
|
|
guard let capability = dataCopy.readString(at: &offset) else { return nil }
|
|
capabilities?.append(capability)
|
|
}
|
|
}
|
|
|
|
var reason: String? = nil
|
|
if hasReason {
|
|
reason = dataCopy.readString(at: &offset)
|
|
}
|
|
|
|
return VersionAck(agreedVersion: agreedVersion,
|
|
serverVersion: serverVersion,
|
|
platform: platform,
|
|
capabilities: capabilities,
|
|
rejected: rejected,
|
|
reason: reason)
|
|
}
|
|
}
|
|
|
|
// 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)"
|
|
}
|
|
}
|
|
}
|
|
|
|
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) -> AttributedString? {
|
|
return _cachedFormattedText["\(isDark)"]
|
|
}
|
|
|
|
func setCachedFormattedText(_ text: AttributedString, isDark: Bool) {
|
|
_cachedFormattedText["\(isDark)"] = 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
|
|
}
|
|
}
|
|
|
|
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
|
|
|
|
// Delivery confirmation methods
|
|
func didReceiveDeliveryAck(_ ack: DeliveryAck)
|
|
func didReceiveReadReceipt(_ receipt: ReadReceipt)
|
|
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus)
|
|
|
|
// Peer availability tracking
|
|
func peerAvailabilityChanged(_ peerID: String, available: Bool)
|
|
}
|
|
|
|
// Provide default implementation to make it effectively optional
|
|
extension BitchatDelegate {
|
|
func isFavorite(fingerprint: String) -> Bool {
|
|
return false
|
|
}
|
|
|
|
func didReceiveDeliveryAck(_ ack: DeliveryAck) {
|
|
// Default empty implementation
|
|
}
|
|
|
|
func didReceiveReadReceipt(_ receipt: ReadReceipt) {
|
|
// Default empty implementation
|
|
}
|
|
|
|
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {
|
|
// Default empty implementation
|
|
}
|
|
|
|
func peerAvailabilityChanged(_ peerID: String, available: Bool) {
|
|
// Default empty implementation
|
|
}
|
|
}
|