Files
bitchat/bitchatTests/Services/BLEAnnounceHandlingPolicyTests.swift
T
jackandClaude Opus 4.8 0558c2bc82 Pin announce signing keys to stop mesh identity spoofing
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; the registry/persisted identity was then overwritten
unconditionally, enabling mesh nickname spoofing and forged attribution
of signed public/broadcast messages.

Fix: TOFU-pin the Ed25519 signing key per peer (noise-key-derived
peerID) on the announce path.

- BLEAnnounceTrustPolicy rejects announces whose signing key differs
  from the one already recorded for the peer (.signingKeyMismatch).
- BLEPeerRegistry.upsertVerifiedAnnounce refuses to replace a pinned
  signing key (returns nil) and never drops a pinned key.
- BLEAnnounceHandler falls back to the persisted cryptographic identity
  (persistedSigningPublicKey) when the registry has no signing key, so
  the pin survives registry eviction and app restart.
- SecureIdentityStateManager.upsertCryptographicIdentity refuses to
  replace a persisted signing key with a different one, and the
  cryptographic identities (incl. the pin) now live in the encrypted,
  persisted IdentityCache with synchronous, teardown-safe saves.

Rebased onto main and integrated with #1432 (Noise session identity
binding + signed leaves): both are complementary. #1432's signed-leave
verification reads the same registry/persisted signing key that this
change protects from announce-path poisoning. main's evolution is kept:
BLEPeerRegistry.upsertVerifiedAnnounce still takes capabilities/
bridgeGeohash (nil return propagates as a refusal), the handler's
linkBoundToOtherPeer env is preserved, CryptographicIdentity keeps
main's field set, and EphemeralIdentity uses main's initializer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 11:06:37 +02:00

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)
}
}