mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 04:25:21 +00:00
BLE announce trust verified the packet signature against the Ed25519 signing key carried inside the same announce, and the trust policy only rejected on noise-key mismatch. Since peerIDs derive from the broadcast (public) noise key, an on-mesh attacker could replay a victim's peerID+noiseKey with their own signing key, nickname, and a valid self-signature; BLEPeerRegistry.upsertVerifiedAnnounce then overwrote the victim's entry unconditionally. That enabled mesh nickname spoofing and, via the persisted identity, forged attribution of signed public/broadcast messages. Fix: TOFU-pin the signing key per peer (noise-key-derived peerID). - BLEAnnounceTrustPolicy now rejects announces whose signing key differs from the one already recorded for the peer (.signingKeyMismatch) and logs a security event. - BLEPeerRegistry.upsertVerifiedAnnounce refuses to replace a pinned signing key (returns nil), closing the race where the pre-barrier trust check reads the registry outside the collections barrier. It also never drops a pinned key when an announce omits one. - BLEAnnounceHandler skips registry upsert, topology updates, and identity persistence for rejected announces. No wire-format change: the packet signature already covers senderID, timestamp, and the full announce payload (noise key, signing key, nickname), so mixed-version meshes are unaffected. First contact for an unknown peer behaves exactly as before (trust on first use); the pin is scoped to the registry entry lifetime, so a legitimately re-keyed identity recovers after normal peer eviction. Tests: trust-policy signing-key mismatch/match cases, registry pinning (attacker upsert refused, legitimate re-announce accepted, omitted key keeps pin), handler-level pinned-key rejection, and an end-to-end test with real Ed25519 keys showing a fully self-consistent attacker announce cannot displace the victim's pinned identity. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
278 lines
9.5 KiB
Swift
278 lines
9.5 KiB
Swift
import BitFoundation
|
|
import Foundation
|
|
import Testing
|
|
@testable import bitchat
|
|
|
|
struct BLEAnnounceHandlingPolicyTests {
|
|
@Test
|
|
func preflightAcceptsMatchingFreshAnnounce() throws {
|
|
let now = Date(timeIntervalSince1970: 1_000)
|
|
let noiseKey = Data(repeating: 0x11, count: 32)
|
|
let peerID = PeerID(publicKey: noiseKey)
|
|
let packet = try makeAnnouncePacket(noisePublicKey: noiseKey, peerID: peerID, timestamp: timestamp(now))
|
|
|
|
let decision = BLEAnnouncePreflightPolicy.evaluate(
|
|
packet: packet,
|
|
from: peerID,
|
|
localPeerID: PeerID(str: "0102030405060708"),
|
|
now: now
|
|
)
|
|
|
|
guard case .accept(let accepted) = decision else {
|
|
Issue.record("Expected announce preflight acceptance")
|
|
return
|
|
}
|
|
#expect(accepted.announcement.nickname == "Alice")
|
|
#expect(accepted.derivedPeerID == peerID)
|
|
}
|
|
|
|
@Test
|
|
func preflightRejectsMalformedPayload() {
|
|
let now = Date(timeIntervalSince1970: 1_000)
|
|
let peerID = PeerID(str: "1122334455667788")
|
|
let packet = BitchatPacket(
|
|
type: MessageType.announce.rawValue,
|
|
senderID: Data(hexString: peerID.id) ?? Data(),
|
|
recipientID: nil,
|
|
timestamp: timestamp(now),
|
|
payload: Data([0x01, 0x20]),
|
|
signature: nil,
|
|
ttl: TransportConfig.messageTTLDefault
|
|
)
|
|
|
|
let decision = BLEAnnouncePreflightPolicy.evaluate(
|
|
packet: packet,
|
|
from: peerID,
|
|
localPeerID: PeerID(str: "0102030405060708"),
|
|
now: now
|
|
)
|
|
|
|
guard case .reject(.malformed) = decision else {
|
|
Issue.record("Expected malformed announce rejection")
|
|
return
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func preflightRejectsSenderMismatchWithDerivedPeerID() throws {
|
|
let now = Date(timeIntervalSince1970: 1_000)
|
|
let noiseKey = Data(repeating: 0x22, count: 32)
|
|
let derivedPeerID = PeerID(publicKey: noiseKey)
|
|
let claimedPeerID = PeerID(str: "1122334455667788")
|
|
let packet = try makeAnnouncePacket(noisePublicKey: noiseKey, peerID: claimedPeerID, timestamp: timestamp(now))
|
|
|
|
let decision = BLEAnnouncePreflightPolicy.evaluate(
|
|
packet: packet,
|
|
from: claimedPeerID,
|
|
localPeerID: PeerID(str: "0102030405060708"),
|
|
now: now
|
|
)
|
|
|
|
guard case .reject(.senderMismatch(let rejectedDerivedPeerID)) = decision else {
|
|
Issue.record("Expected sender mismatch rejection")
|
|
return
|
|
}
|
|
#expect(rejectedDerivedPeerID == derivedPeerID)
|
|
}
|
|
|
|
@Test
|
|
func preflightRejectsSelfAnnounceAfterSenderMatches() throws {
|
|
let now = Date(timeIntervalSince1970: 1_000)
|
|
let noiseKey = Data(repeating: 0x33, count: 32)
|
|
let peerID = PeerID(publicKey: noiseKey)
|
|
let packet = try makeAnnouncePacket(noisePublicKey: noiseKey, peerID: peerID, timestamp: timestamp(now))
|
|
|
|
let decision = BLEAnnouncePreflightPolicy.evaluate(
|
|
packet: packet,
|
|
from: peerID,
|
|
localPeerID: peerID,
|
|
now: now
|
|
)
|
|
|
|
guard case .reject(.selfAnnounce) = decision else {
|
|
Issue.record("Expected self announce rejection")
|
|
return
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func preflightRejectsStaleAnnounceWithAge() throws {
|
|
let now = Date(timeIntervalSince1970: 1_000)
|
|
let noiseKey = Data(repeating: 0x44, count: 32)
|
|
let peerID = PeerID(publicKey: noiseKey)
|
|
let oldTimestamp = UInt64((now.timeIntervalSince1970 - 901) * 1000)
|
|
let packet = try makeAnnouncePacket(noisePublicKey: noiseKey, peerID: peerID, timestamp: oldTimestamp)
|
|
|
|
let decision = BLEAnnouncePreflightPolicy.evaluate(
|
|
packet: packet,
|
|
from: peerID,
|
|
localPeerID: PeerID(str: "0102030405060708"),
|
|
now: now
|
|
)
|
|
|
|
guard case .reject(.stale(let ageSeconds)) = decision else {
|
|
Issue.record("Expected stale announce rejection")
|
|
return
|
|
}
|
|
#expect(abs(ageSeconds - 901) < 0.001)
|
|
}
|
|
|
|
@Test
|
|
func trustPolicyRequiresSignature() {
|
|
let decision = BLEAnnounceTrustPolicy.evaluate(
|
|
hasSignature: false,
|
|
signatureValid: false,
|
|
existingNoisePublicKey: nil,
|
|
announcedNoisePublicKey: Data(repeating: 0x11, count: 32),
|
|
existingSigningPublicKey: nil,
|
|
announcedSigningPublicKey: Data(repeating: 0x99, count: 32)
|
|
)
|
|
|
|
#expect(decision == .reject(.missingSignature))
|
|
#expect(!decision.isVerified)
|
|
}
|
|
|
|
@Test
|
|
func trustPolicyRejectsInvalidSignature() {
|
|
let decision = BLEAnnounceTrustPolicy.evaluate(
|
|
hasSignature: true,
|
|
signatureValid: false,
|
|
existingNoisePublicKey: nil,
|
|
announcedNoisePublicKey: Data(repeating: 0x11, count: 32),
|
|
existingSigningPublicKey: nil,
|
|
announcedSigningPublicKey: Data(repeating: 0x99, count: 32)
|
|
)
|
|
|
|
#expect(decision == .reject(.invalidSignature))
|
|
}
|
|
|
|
@Test
|
|
func trustPolicyRejectsExistingKeyMismatch() {
|
|
let decision = BLEAnnounceTrustPolicy.evaluate(
|
|
hasSignature: true,
|
|
signatureValid: true,
|
|
existingNoisePublicKey: Data(repeating: 0xAA, count: 32),
|
|
announcedNoisePublicKey: Data(repeating: 0xBB, count: 32),
|
|
existingSigningPublicKey: nil,
|
|
announcedSigningPublicKey: Data(repeating: 0x99, count: 32)
|
|
)
|
|
|
|
#expect(decision == .reject(.keyMismatch))
|
|
}
|
|
|
|
@Test
|
|
func trustPolicyAcceptsValidSignatureWithMatchingExistingKey() {
|
|
let noiseKey = Data(repeating: 0xCC, count: 32)
|
|
|
|
let decision = BLEAnnounceTrustPolicy.evaluate(
|
|
hasSignature: true,
|
|
signatureValid: true,
|
|
existingNoisePublicKey: noiseKey,
|
|
announcedNoisePublicKey: noiseKey,
|
|
existingSigningPublicKey: nil,
|
|
announcedSigningPublicKey: Data(repeating: 0x99, count: 32)
|
|
)
|
|
|
|
#expect(decision == .verified)
|
|
#expect(decision.isVerified)
|
|
}
|
|
|
|
@Test
|
|
func trustPolicyRejectsPinnedSigningKeyMismatchEvenWithValidSignature() {
|
|
let noiseKey = Data(repeating: 0xCC, count: 32)
|
|
|
|
// Attacker replays the victim's noiseKey/peerID with their own signing
|
|
// key and a valid self-signature; the pinned key must win.
|
|
let decision = BLEAnnounceTrustPolicy.evaluate(
|
|
hasSignature: true,
|
|
signatureValid: true,
|
|
existingNoisePublicKey: noiseKey,
|
|
announcedNoisePublicKey: noiseKey,
|
|
existingSigningPublicKey: Data(repeating: 0x99, count: 32),
|
|
announcedSigningPublicKey: Data(repeating: 0x66, count: 32)
|
|
)
|
|
|
|
#expect(decision == .reject(.signingKeyMismatch))
|
|
#expect(!decision.isVerified)
|
|
}
|
|
|
|
@Test
|
|
func trustPolicyAcceptsMatchingPinnedSigningKey() {
|
|
let noiseKey = Data(repeating: 0xCC, count: 32)
|
|
let signingKey = Data(repeating: 0x99, count: 32)
|
|
|
|
let decision = BLEAnnounceTrustPolicy.evaluate(
|
|
hasSignature: true,
|
|
signatureValid: true,
|
|
existingNoisePublicKey: noiseKey,
|
|
announcedNoisePublicKey: noiseKey,
|
|
existingSigningPublicKey: signingKey,
|
|
announcedSigningPublicKey: signingKey
|
|
)
|
|
|
|
#expect(decision == .verified)
|
|
}
|
|
|
|
@Test
|
|
func responsePolicyConnectsOnlyForDirectNewOrReconnectedPeers() {
|
|
let directNew = BLEAnnounceResponsePolicy.plan(
|
|
isDirectAnnounce: true,
|
|
isNewPeer: true,
|
|
isReconnectedPeer: false,
|
|
shouldSendAnnounceBack: false
|
|
)
|
|
#expect(directNew.shouldNotifyPeerConnected)
|
|
#expect(directNew.shouldScheduleInitialSync)
|
|
#expect(directNew.shouldScheduleAfterglow)
|
|
|
|
let relayedNew = BLEAnnounceResponsePolicy.plan(
|
|
isDirectAnnounce: false,
|
|
isNewPeer: true,
|
|
isReconnectedPeer: false,
|
|
shouldSendAnnounceBack: true
|
|
)
|
|
#expect(!relayedNew.shouldNotifyPeerConnected)
|
|
#expect(!relayedNew.shouldScheduleInitialSync)
|
|
#expect(relayedNew.shouldSendAnnounceBack)
|
|
#expect(relayedNew.shouldScheduleAfterglow)
|
|
|
|
let directExisting = BLEAnnounceResponsePolicy.plan(
|
|
isDirectAnnounce: true,
|
|
isNewPeer: false,
|
|
isReconnectedPeer: false,
|
|
shouldSendAnnounceBack: false
|
|
)
|
|
#expect(!directExisting.shouldNotifyPeerConnected)
|
|
#expect(!directExisting.shouldScheduleInitialSync)
|
|
#expect(!directExisting.shouldScheduleAfterglow)
|
|
}
|
|
|
|
private func makeAnnouncePacket(
|
|
noisePublicKey: Data,
|
|
peerID: PeerID,
|
|
timestamp: UInt64
|
|
) throws -> BitchatPacket {
|
|
let announcement = AnnouncementPacket(
|
|
nickname: "Alice",
|
|
noisePublicKey: noisePublicKey,
|
|
signingPublicKey: Data(repeating: 0x99, count: 32),
|
|
directNeighbors: nil
|
|
)
|
|
let payload = try #require(announcement.encode())
|
|
|
|
return BitchatPacket(
|
|
type: MessageType.announce.rawValue,
|
|
senderID: Data(hexString: peerID.id) ?? Data(),
|
|
recipientID: nil,
|
|
timestamp: timestamp,
|
|
payload: payload,
|
|
signature: nil,
|
|
ttl: TransportConfig.messageTTLDefault
|
|
)
|
|
}
|
|
|
|
private func timestamp(_ date: Date) -> UInt64 {
|
|
UInt64(date.timeIntervalSince1970 * 1000)
|
|
}
|
|
}
|