Merge remote-tracking branch 'origin/feat/capability-bits' into feat/integration-all

This commit is contained in:
jack
2026-07-06 22:06:33 +02:00
7 changed files with 182 additions and 5 deletions
+31 -1
View File
@@ -1,3 +1,4 @@
import BitFoundation
import Foundation import Foundation
// MARK: - Protocol TLV Packets // MARK: - Protocol TLV Packets
@@ -7,12 +8,28 @@ struct AnnouncementPacket {
let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement) let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement)
let signingPublicKey: Data // Ed25519 public key for signing let signingPublicKey: Data // Ed25519 public key for signing
let directNeighbors: [Data]? // 8-byte peer IDs 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 { private enum TLVType: UInt8 {
case nickname = 0x01 case nickname = 0x01
case noisePublicKey = 0x02 case noisePublicKey = 0x02
case signingPublicKey = 0x03 case signingPublicKey = 0x03
case directNeighbors = 0x04 case directNeighbors = 0x04
case capabilities = 0x05
} }
func encode() -> Data? { 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 return data
} }
@@ -57,6 +83,7 @@ struct AnnouncementPacket {
var noisePublicKey: Data? var noisePublicKey: Data?
var signingPublicKey: Data? var signingPublicKey: Data?
var directNeighbors: [Data]? var directNeighbors: [Data]?
var capabilities: PeerCapabilities?
while offset + 2 <= data.count { while offset + 2 <= data.count {
let typeRaw = data[offset] let typeRaw = data[offset]
@@ -87,6 +114,8 @@ struct AnnouncementPacket {
} }
directNeighbors = neighbors directNeighbors = neighbors
} }
case .capabilities:
capabilities = PeerCapabilities(encoded: Data(value))
} }
} else { } else {
// Unknown TLV; skip (tolerant decoder for forward compatibility) // Unknown TLV; skip (tolerant decoder for forward compatibility)
@@ -99,7 +128,8 @@ struct AnnouncementPacket {
nickname: nickname, nickname: nickname,
noisePublicKey: noisePublicKey, noisePublicKey: noisePublicKey,
signingPublicKey: signingPublicKey, signingPublicKey: signingPublicKey,
directNeighbors: directNeighbors directNeighbors: directNeighbors,
capabilities: capabilities
) )
} }
} }
@@ -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 = []
}
+9 -2
View File
@@ -9,6 +9,7 @@ struct BLEPeerInfo: Equatable {
var signingPublicKey: Data? var signingPublicKey: Data?
var isVerifiedNickname: Bool var isVerifiedNickname: Bool
var lastSeen: Date var lastSeen: Date
var capabilities: PeerCapabilities = []
} }
struct BLEPeerAnnounceUpdate: Equatable { struct BLEPeerAnnounceUpdate: Equatable {
@@ -107,6 +108,10 @@ struct BLEPeerRegistry {
peers[peerID]?.noisePublicKey?.sha256Fingerprint() peers[peerID]?.noisePublicKey?.sha256Fingerprint()
} }
func capabilities(for peerID: PeerID) -> PeerCapabilities {
peers[peerID.toShort()]?.capabilities ?? []
}
func displayNicknames(selfNickname: String) -> [PeerID: String] { func displayNicknames(selfNickname: String) -> [PeerID: String] {
let connected = peers.filter { $0.value.isConnected } let connected = peers.filter { $0.value.isConnected }
let tuples = connected.map { ($0.key, $0.value.nickname, true) } let tuples = connected.map { ($0.key, $0.value.nickname, true) }
@@ -157,7 +162,8 @@ struct BLEPeerRegistry {
noisePublicKey: Data, noisePublicKey: Data,
signingPublicKey: Data?, signingPublicKey: Data?,
isConnected: Bool, isConnected: Bool,
now: Date now: Date,
capabilities: PeerCapabilities = []
) -> BLEPeerAnnounceUpdate { ) -> BLEPeerAnnounceUpdate {
let existing = peers[peerID] let existing = peers[peerID]
let update = BLEPeerAnnounceUpdate( let update = BLEPeerAnnounceUpdate(
@@ -173,7 +179,8 @@ struct BLEPeerRegistry {
noisePublicKey: noisePublicKey, noisePublicKey: noisePublicKey,
signingPublicKey: signingPublicKey, signingPublicKey: signingPublicKey,
isVerifiedNickname: true, isVerifiedNickname: true,
lastSeen: now lastSeen: now,
capabilities: capabilities
) )
return update return update
+10 -2
View File
@@ -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] { func getPeerNicknames() -> [PeerID: String] {
return collectionsQueue.sync { return collectionsQueue.sync {
peerRegistry.displayNicknames(selfNickname: myNickname) peerRegistry.displayNicknames(selfNickname: myNickname)
@@ -1261,7 +1267,8 @@ final class BLEService: NSObject {
nickname: myNickname, nickname: myNickname,
noisePublicKey: noisePub, noisePublicKey: noisePub,
signingPublicKey: signingPub, signingPublicKey: signingPub,
directNeighbors: connectedPeerIDs directNeighbors: connectedPeerIDs,
capabilities: PeerCapabilities.localSupported
) )
guard let payload = announcement.encode() else { guard let payload = announcement.encode() else {
@@ -3315,7 +3322,8 @@ extension BLEService {
noisePublicKey: announcement.noisePublicKey, noisePublicKey: announcement.noisePublicKey,
signingPublicKey: announcement.signingPublicKey, signingPublicKey: announcement.signingPublicKey,
isConnected: isConnected, isConnected: isConnected,
now: now now: now,
capabilities: announcement.capabilities ?? []
) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil) ) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
}, },
shouldEmitReconnectLog: { [weak self] peerID, now in shouldEmitReconnectLog: { [weak self] peerID, now in
+37
View File
@@ -1,3 +1,4 @@
import BitFoundation
import Foundation import Foundation
import Testing import Testing
@@ -108,6 +109,42 @@ struct PacketsTests {
#expect(decoded.directNeighbors == nil) #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 @Test
func privateMessagePacketRejectsUnknownTypeAndTruncation() { func privateMessagePacketRejectsUnknownTypeAndTruncation() {
let unknownTLV = Data([0x7F, 0x01, 0x41]) let unknownTLV = Data([0x7F, 0x01, 0x41])
@@ -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)
}
}
@@ -0,0 +1,43 @@
//
// PeerCapabilitiesTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import Foundation
@testable import BitFoundation
struct 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()) == [])
}
}