diff --git a/bitchat/Protocols/Packets.swift b/bitchat/Protocols/Packets.swift index 8914d4b7..309860aa 100644 --- a/bitchat/Protocols/Packets.swift +++ b/bitchat/Protocols/Packets.swift @@ -1,3 +1,4 @@ +import BitFoundation import Foundation // MARK: - Protocol TLV Packets @@ -7,12 +8,28 @@ struct AnnouncementPacket { let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement) let signingPublicKey: Data // Ed25519 public key for signing let directNeighbors: [Data]? // 8-byte peer IDs + let capabilities: PeerCapabilities? // advertised feature bits; nil when absent (old clients) + + init( + nickname: String, + noisePublicKey: Data, + signingPublicKey: Data, + directNeighbors: [Data]?, + capabilities: PeerCapabilities? = nil + ) { + self.nickname = nickname + self.noisePublicKey = noisePublicKey + self.signingPublicKey = signingPublicKey + self.directNeighbors = directNeighbors + self.capabilities = capabilities + } private enum TLVType: UInt8 { case nickname = 0x01 case noisePublicKey = 0x02 case signingPublicKey = 0x03 case directNeighbors = 0x04 + case capabilities = 0x05 } func encode() -> Data? { @@ -48,6 +65,15 @@ struct AnnouncementPacket { } } + // TLV for capabilities (optional) + if let capabilities = capabilities { + let capabilityBytes = capabilities.encoded() + guard capabilityBytes.count <= 255 else { return nil } + data.append(TLVType.capabilities.rawValue) + data.append(UInt8(capabilityBytes.count)) + data.append(capabilityBytes) + } + return data } @@ -57,6 +83,7 @@ struct AnnouncementPacket { var noisePublicKey: Data? var signingPublicKey: Data? var directNeighbors: [Data]? + var capabilities: PeerCapabilities? while offset + 2 <= data.count { let typeRaw = data[offset] @@ -87,6 +114,8 @@ struct AnnouncementPacket { } directNeighbors = neighbors } + case .capabilities: + capabilities = PeerCapabilities(encoded: Data(value)) } } else { // Unknown TLV; skip (tolerant decoder for forward compatibility) @@ -99,7 +128,8 @@ struct AnnouncementPacket { nickname: nickname, noisePublicKey: noisePublicKey, signingPublicKey: signingPublicKey, - directNeighbors: directNeighbors + directNeighbors: directNeighbors, + capabilities: capabilities ) } } diff --git a/bitchat/Protocols/PeerCapabilities+Local.swift b/bitchat/Protocols/PeerCapabilities+Local.swift new file mode 100644 index 00000000..40f97b9f --- /dev/null +++ b/bitchat/Protocols/PeerCapabilities+Local.swift @@ -0,0 +1,7 @@ +import BitFoundation + +extension PeerCapabilities { + /// Capabilities this build advertises in its announce packets. + /// Each feature adds its bit here when it ships. + static let localSupported: PeerCapabilities = [] +} diff --git a/bitchat/Services/BLE/BLEPeerRegistry.swift b/bitchat/Services/BLE/BLEPeerRegistry.swift index 29776748..4db3e931 100644 --- a/bitchat/Services/BLE/BLEPeerRegistry.swift +++ b/bitchat/Services/BLE/BLEPeerRegistry.swift @@ -9,6 +9,7 @@ struct BLEPeerInfo: Equatable { var signingPublicKey: Data? var isVerifiedNickname: Bool var lastSeen: Date + var capabilities: PeerCapabilities = [] } struct BLEPeerAnnounceUpdate: Equatable { @@ -107,6 +108,10 @@ struct BLEPeerRegistry { peers[peerID]?.noisePublicKey?.sha256Fingerprint() } + func capabilities(for peerID: PeerID) -> PeerCapabilities { + peers[peerID.toShort()]?.capabilities ?? [] + } + func displayNicknames(selfNickname: String) -> [PeerID: String] { let connected = peers.filter { $0.value.isConnected } let tuples = connected.map { ($0.key, $0.value.nickname, true) } @@ -157,7 +162,8 @@ struct BLEPeerRegistry { noisePublicKey: Data, signingPublicKey: Data?, isConnected: Bool, - now: Date + now: Date, + capabilities: PeerCapabilities = [] ) -> BLEPeerAnnounceUpdate { let existing = peers[peerID] let update = BLEPeerAnnounceUpdate( @@ -173,7 +179,8 @@ struct BLEPeerRegistry { noisePublicKey: noisePublicKey, signingPublicKey: signingPublicKey, isVerifiedNickname: true, - lastSeen: now + lastSeen: now, + capabilities: capabilities ) return update diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 865a89be..d1ed84f2 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -619,6 +619,12 @@ final class BLEService: NSObject { } } + /// Capabilities the peer advertised in its last verified announce. + /// Empty for peers that predate the capabilities TLV. + func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { + collectionsQueue.sync { peerRegistry.capabilities(for: peerID) } + } + func getPeerNicknames() -> [PeerID: String] { return collectionsQueue.sync { peerRegistry.displayNicknames(selfNickname: myNickname) @@ -1261,7 +1267,8 @@ final class BLEService: NSObject { nickname: myNickname, noisePublicKey: noisePub, signingPublicKey: signingPub, - directNeighbors: connectedPeerIDs + directNeighbors: connectedPeerIDs, + capabilities: PeerCapabilities.localSupported ) guard let payload = announcement.encode() else { @@ -3315,7 +3322,8 @@ extension BLEService { noisePublicKey: announcement.noisePublicKey, signingPublicKey: announcement.signingPublicKey, isConnected: isConnected, - now: now + now: now, + capabilities: announcement.capabilities ?? [] ) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil) }, shouldEmitReconnectLog: { [weak self] peerID, now in diff --git a/bitchatTests/Protocols/PacketsTests.swift b/bitchatTests/Protocols/PacketsTests.swift index 78c244de..2368925a 100644 --- a/bitchatTests/Protocols/PacketsTests.swift +++ b/bitchatTests/Protocols/PacketsTests.swift @@ -1,3 +1,4 @@ +import BitFoundation import Foundation import Testing @@ -108,6 +109,42 @@ struct PacketsTests { #expect(decoded.directNeighbors == nil) } + @Test + func announcementPacketRoundTripsCapabilities() throws { + let capabilities: PeerCapabilities = [.prekeys, .board, .meshDiagnostics] + let packet = AnnouncementPacket( + nickname: "alice", + noisePublicKey: Data(repeating: 0x11, count: 32), + signingPublicKey: Data(repeating: 0x22, count: 32), + directNeighbors: nil, + capabilities: capabilities + ) + + let encoded = try #require(packet.encode()) + let decoded = try #require(AnnouncementPacket.decode(from: encoded)) + #expect(decoded.capabilities == capabilities) + } + + @Test + func announcementPacketWithoutCapabilitiesDecodesNilAndUnknownBitsSurvive() throws { + let legacy = try #require( + AnnouncementPacket( + nickname: "alice", + noisePublicKey: Data(repeating: 0x11, count: 32), + signingPublicKey: Data(repeating: 0x22, count: 32), + directNeighbors: nil + ).encode() + ) + // The TLV is emitted only when capabilities are set, so legacy peers + // (and this packet) decode as nil rather than empty. + #expect(try #require(AnnouncementPacket.decode(from: legacy)).capabilities == nil) + + var withFutureBits = legacy + withFutureBits.append(makeTLV(type: 0x05, value: Data([0x80, 0x01]))) + let decoded = try #require(AnnouncementPacket.decode(from: withFutureBits)) + #expect(decoded.capabilities?.rawValue == 0x0180) + } + @Test func privateMessagePacketRejectsUnknownTypeAndTruncation() { let unknownTLV = Data([0x7F, 0x01, 0x41]) diff --git a/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift b/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift new file mode 100644 index 00000000..94858d9b --- /dev/null +++ b/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift @@ -0,0 +1,45 @@ +import Foundation + +/// Feature capabilities a peer advertises in its announce packet. +/// +/// Encoded as a little-endian bitfield with trailing zero bytes dropped, so the +/// wire form grows only when high bits are assigned. Decoders keep the low 64 +/// bits and ignore any longer field, and unknown bits are preserved verbatim — +/// old clients skip the TLV entirely, new clients degrade per-feature. +public struct PeerCapabilities: OptionSet, Equatable, Hashable, Sendable { + public let rawValue: UInt64 + + public init(rawValue: UInt64) { + self.rawValue = rawValue + } + + public static let prekeys = PeerCapabilities(rawValue: 1 << 0) + public static let wifiBulk = PeerCapabilities(rawValue: 1 << 1) + public static let gateway = PeerCapabilities(rawValue: 1 << 2) + public static let groups = PeerCapabilities(rawValue: 1 << 3) + public static let board = PeerCapabilities(rawValue: 1 << 4) + public static let vouch = PeerCapabilities(rawValue: 1 << 5) + public static let meshDiagnostics = PeerCapabilities(rawValue: 1 << 6) + + /// Minimal little-endian byte encoding; always at least one byte so an + /// empty set is distinguishable from an absent TLV. + public func encoded() -> Data { + var value = rawValue + var bytes = Data() + repeat { + bytes.append(UInt8(truncatingIfNeeded: value)) + value >>= 8 + } while value != 0 + return bytes + } + + /// Accepts any length; bytes beyond the low 64 bits are ignored for + /// forward compatibility. + public init(encoded data: Data) { + var value: UInt64 = 0 + for (index, byte) in data.prefix(8).enumerated() { + value |= UInt64(byte) << (8 * index) + } + self.init(rawValue: value) + } +} diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift new file mode 100644 index 00000000..1c83530f --- /dev/null +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift @@ -0,0 +1,43 @@ +// +// PeerCapabilitiesTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +@testable import BitFoundation + +struct PeerCapabilitiesTests { + @Test + func encodingIsMinimalAndRoundTrips() { + #expect(PeerCapabilities([]).encoded() == Data([0x00])) + #expect(PeerCapabilities.prekeys.encoded() == Data([0x01])) + #expect(PeerCapabilities.meshDiagnostics.encoded() == Data([0x40])) + + let high = PeerCapabilities(rawValue: 1 << 9) + #expect(high.encoded() == Data([0x00, 0x02])) + + let all: PeerCapabilities = [.prekeys, .wifiBulk, .gateway, .groups, .board, .vouch, .meshDiagnostics] + #expect(PeerCapabilities(encoded: all.encoded()) == all) + #expect(PeerCapabilities(encoded: high.encoded()) == high) + #expect(PeerCapabilities(encoded: PeerCapabilities([]).encoded()) == []) + } + + @Test + func decodingToleratesUnknownBitsAndOversizedFields() { + // Unknown bits survive a round-trip untouched. + let unknown = PeerCapabilities(encoded: Data([0xFF, 0xFF])) + #expect(unknown.rawValue == 0xFFFF) + #expect(unknown.contains(.gateway)) + + // Fields longer than 8 bytes keep the low 64 bits and ignore the rest. + let oversized = Data([0x01] + [UInt8](repeating: 0x00, count: 7) + [0xAA, 0xBB]) + #expect(PeerCapabilities(encoded: oversized) == .prekeys) + + // Empty value decodes to no capabilities. + #expect(PeerCapabilities(encoded: Data()) == []) + } +}