Files
bitchat/bitchatTests/Services/BLEAnnounceHandlerTests.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

968 lines
41 KiB
Swift

import BitFoundation
import Foundation
import Testing
@testable import bitchat
struct BLEAnnounceHandlerTests {
private final class Recorder {
var existingNoisePublicKey: Data?
var existingSigningPublicKey: Data?
var persistedSigningPublicKey: Data?
var persistedSigningKeyQueries: [PeerID] = []
var signatureValid = true
var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false)
var linkBoundToOtherPeer = false
var upsertResult: BLEPeerAnnounceUpdate? = BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
var dedupSeenIDs: Set<String> = []
var shouldEmitReconnectLogResult = true
var verifySignatureCalls: [(packet: BitchatPacket, signingPublicKey: Data)] = []
var barrierCount = 0
var upsertCalls: [(peerID: PeerID, announcement: AnnouncementPacket, isConnected: Bool, now: Date)] = []
var reconnectLogQueries: [PeerID] = []
var topologyUpdates: [(peerID: PeerID, neighbors: [Data])] = []
var persistedIdentities: [AnnouncementPacket] = []
var dedupContainsQueries: [String] = []
var dedupMarkedIDs: [String] = []
var uiEventDeliveries: [(peerID: PeerID, notifyPeerConnected: Bool, scheduleInitialSync: Bool)] = []
var trackedPackets: [BitchatPacket] = []
var announceBacks = 0
var afterglowDelays: [TimeInterval] = []
}
private func makeHandler(
recorder: Recorder,
localPeerID: PeerID = PeerID(str: "0102030405060708"),
now: Date = Date(timeIntervalSince1970: 1_000)
) -> BLEAnnounceHandler {
let environment = BLEAnnounceHandlerEnvironment(
localPeerID: { localPeerID },
messageTTL: TransportConfig.messageTTLDefault,
now: { now },
existingPeerKeys: { _ in (recorder.existingNoisePublicKey, recorder.existingSigningPublicKey) },
persistedSigningPublicKey: { peerID in
recorder.persistedSigningKeyQueries.append(peerID)
return recorder.persistedSigningPublicKey
},
verifySignature: { packet, signingPublicKey in
recorder.verifySignatureCalls.append((packet, signingPublicKey))
return recorder.signatureValid
},
linkState: { _ in recorder.linkState },
linkBoundToOtherPeer: { _, _ in recorder.linkBoundToOtherPeer },
withRegistryBarrier: { body in
recorder.barrierCount += 1
body()
},
upsertVerifiedAnnounce: { peerID, announcement, isConnected, now in
recorder.upsertCalls.append((peerID, announcement, isConnected, now))
return recorder.upsertResult
},
shouldEmitReconnectLog: { peerID, _ in
recorder.reconnectLogQueries.append(peerID)
return recorder.shouldEmitReconnectLogResult
},
updateTopology: { peerID, neighbors in
recorder.topologyUpdates.append((peerID, neighbors))
},
persistIdentity: { announcement in
recorder.persistedIdentities.append(announcement)
},
dedupContains: { id in
recorder.dedupContainsQueries.append(id)
return recorder.dedupSeenIDs.contains(id)
},
dedupMarkProcessed: { id in
recorder.dedupMarkedIDs.append(id)
},
deliverAnnounceUIEvents: { peerID, notifyPeerConnected, scheduleInitialSync in
recorder.uiEventDeliveries.append((peerID, notifyPeerConnected, scheduleInitialSync))
},
trackPacketSeen: { packet in
recorder.trackedPackets.append(packet)
},
sendAnnounceBack: {
recorder.announceBacks += 1
},
scheduleAfterglow: { delay in
recorder.afterglowDelays.append(delay)
}
)
return BLEAnnounceHandler(environment: environment)
}
@Test
func verifiedNewPeerAnnounceUpsertsNotifiesSyncsAndAnnouncesBack() 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),
signature: Data(repeating: 0xEE, count: 64)
)
let recorder = Recorder()
recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil)
let handler = makeHandler(recorder: recorder, now: now)
let result = handler.handle(packet, from: peerID)
#expect(result?.peerID == peerID)
#expect(result?.announcement.noisePublicKey == noiseKey)
#expect(result?.isDirectAnnounce == true)
#expect(result?.isVerified == true)
#expect(recorder.verifySignatureCalls.count == 1)
#expect(recorder.verifySignatureCalls.first?.signingPublicKey == Data(repeating: 0x99, count: 32))
#expect(recorder.barrierCount == 1)
#expect(recorder.upsertCalls.count == 1)
#expect(recorder.upsertCalls.first?.peerID == peerID)
#expect(recorder.upsertCalls.first?.announcement.nickname == "Alice")
#expect(recorder.upsertCalls.first?.isConnected == true)
#expect(recorder.upsertCalls.first?.now == now)
#expect(recorder.persistedIdentities.count == 1)
#expect(recorder.persistedIdentities.first?.noisePublicKey == noiseKey)
#expect(recorder.uiEventDeliveries.count == 1)
#expect(recorder.uiEventDeliveries.first?.peerID == peerID)
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == true)
#expect(recorder.uiEventDeliveries.first?.scheduleInitialSync == true)
#expect(recorder.trackedPackets.count == 1)
#expect(recorder.dedupMarkedIDs == ["announce-back-\(peerID)"])
#expect(recorder.announceBacks == 1)
#expect(recorder.afterglowDelays.count == 1)
}
@Test
func afterglowDelayStaysWithinConfiguredRange() throws {
let now = Date(timeIntervalSince1970: 1_000)
let noiseKey = Data(repeating: 0x12, count: 32)
let peerID = PeerID(publicKey: noiseKey)
let packet = try makeAnnouncePacket(
noisePublicKey: noiseKey,
peerID: peerID,
timestamp: timestamp(now),
signature: Data(repeating: 0xEE, count: 64)
)
let recorder = Recorder()
recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil)
let handler = makeHandler(recorder: recorder, now: now)
for _ in 0..<8 {
handler.handle(packet, from: peerID)
}
#expect(recorder.afterglowDelays.count == 8)
for delay in recorder.afterglowDelays {
#expect(delay >= 0.3 && delay <= 0.6)
}
}
@Test
func unverifiedAnnounceWithoutSignatureSkipsUpsertAndConnectNotify() throws {
let now = Date(timeIntervalSince1970: 1_000)
let noiseKey = Data(repeating: 0x22, count: 32)
let peerID = PeerID(publicKey: noiseKey)
let packet = try makeAnnouncePacket(
noisePublicKey: noiseKey,
peerID: peerID,
timestamp: timestamp(now),
signature: nil
)
let recorder = Recorder()
let handler = makeHandler(recorder: recorder, now: now)
let result = handler.handle(packet, from: peerID)
#expect(result?.peerID == peerID)
#expect(result?.announcement.noisePublicKey == noiseKey)
#expect(result?.isVerified == false)
#expect(recorder.verifySignatureCalls.isEmpty)
#expect(recorder.barrierCount == 1)
#expect(recorder.upsertCalls.isEmpty)
#expect(recorder.topologyUpdates.isEmpty)
#expect(recorder.afterglowDelays.isEmpty)
// Original behavior: list refresh, identity persistence, sync tracking
// and announce-back still occur for unverified announces.
#expect(recorder.uiEventDeliveries.count == 1)
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
#expect(recorder.uiEventDeliveries.first?.scheduleInitialSync == false)
// Identity persistence MUST NOT occur for unverified announces:
// persisting would let an attacker who replays a victim's noisePublicKey
// overwrite the victim's stored signing key/nickname (identity poisoning).
#expect(recorder.persistedIdentities.isEmpty)
#expect(recorder.trackedPackets.count == 1)
#expect(recorder.announceBacks == 1)
}
@Test
func invalidSignatureSkipsUpsertAndConnectNotify() throws {
let now = Date(timeIntervalSince1970: 1_000)
let noiseKey = Data(repeating: 0x23, count: 32)
let peerID = PeerID(publicKey: noiseKey)
let packet = try makeAnnouncePacket(
noisePublicKey: noiseKey,
peerID: peerID,
timestamp: timestamp(now),
signature: Data(repeating: 0xEE, count: 64)
)
let recorder = Recorder()
recorder.signatureValid = false
let handler = makeHandler(recorder: recorder, now: now)
let result = handler.handle(packet, from: peerID)
#expect(result?.isVerified == false)
#expect(recorder.verifySignatureCalls.count == 1)
#expect(recorder.upsertCalls.isEmpty)
#expect(recorder.uiEventDeliveries.count == 1)
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
}
@Test
func malformedAnnounceIsNoOp() {
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 recorder = Recorder()
let handler = makeHandler(recorder: recorder, now: now)
let result = handler.handle(packet, from: peerID)
#expect(result == nil)
expectNoSideEffects(recorder)
}
@Test
func selfAnnounceIsNoOp() 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),
signature: Data(repeating: 0xEE, count: 64)
)
let recorder = Recorder()
let handler = makeHandler(recorder: recorder, localPeerID: peerID, now: now)
let result = handler.handle(packet, from: peerID)
#expect(result == nil)
expectNoSideEffects(recorder)
}
@Test
func staleAnnounceIsNoOp() throws {
let now = Date(timeIntervalSince1970: 1_000)
let noiseKey = Data(repeating: 0x44, count: 32)
let peerID = PeerID(publicKey: noiseKey)
let staleTimestamp = UInt64((now.timeIntervalSince1970 - 901) * 1000)
let packet = try makeAnnouncePacket(
noisePublicKey: noiseKey,
peerID: peerID,
timestamp: staleTimestamp,
signature: Data(repeating: 0xEE, count: 64)
)
let recorder = Recorder()
let handler = makeHandler(recorder: recorder, now: now)
let result = handler.handle(packet, from: peerID)
#expect(result == nil)
expectNoSideEffects(recorder)
}
@Test
func reconnectedPeerNotifiesConnectionWithoutAfterglow() throws {
let now = Date(timeIntervalSince1970: 1_000)
let noiseKey = Data(repeating: 0x55, count: 32)
let peerID = PeerID(publicKey: noiseKey)
let packet = try makeAnnouncePacket(
noisePublicKey: noiseKey,
peerID: peerID,
timestamp: timestamp(now),
signature: Data(repeating: 0xEE, count: 64)
)
let recorder = Recorder()
recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: true, previousNickname: "Alice")
let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID)
#expect(recorder.uiEventDeliveries.count == 1)
#expect(recorder.uiEventDeliveries.first?.peerID == peerID)
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == true)
#expect(recorder.uiEventDeliveries.first?.scheduleInitialSync == true)
#expect(recorder.reconnectLogQueries == [peerID])
#expect(recorder.afterglowDelays.isEmpty)
}
@Test
func relayedNewPeerSchedulesAfterglowWithoutConnectNotify() throws {
let now = Date(timeIntervalSince1970: 1_000)
let noiseKey = Data(repeating: 0x66, count: 32)
let peerID = PeerID(publicKey: noiseKey)
let packet = try makeAnnouncePacket(
noisePublicKey: noiseKey,
peerID: peerID,
timestamp: timestamp(now),
signature: Data(repeating: 0xEE, count: 64),
ttl: TransportConfig.messageTTLDefault - 1
)
let recorder = Recorder()
recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil)
let handler = makeHandler(recorder: recorder, now: now)
let result = handler.handle(packet, from: peerID)
#expect(result?.isDirectAnnounce == false)
#expect(result?.isVerified == true)
#expect(recorder.upsertCalls.count == 1)
#expect(recorder.upsertCalls.first?.isConnected == false)
#expect(recorder.uiEventDeliveries.count == 1)
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
#expect(recorder.uiEventDeliveries.first?.scheduleInitialSync == false)
#expect(recorder.afterglowDelays.count == 1)
}
/// TTL is unsigned, so a replayed announce with its TTL restored looks
/// "direct" — but it arrives on a link another peer already owns. That
/// link must not shortcut the (possibly absent) claimed peer into
/// "connected".
@Test
func directAnnounceOnLinkBoundToAnotherPeerDoesNotMarkConnected() throws {
let now = Date(timeIntervalSince1970: 1_000)
let noiseKey = Data(repeating: 0x88, count: 32)
let peerID = PeerID(publicKey: noiseKey)
let packet = try makeAnnouncePacket(
noisePublicKey: noiseKey,
peerID: peerID,
timestamp: timestamp(now),
signature: Data(repeating: 0xEE, count: 64)
)
let recorder = Recorder()
recorder.linkBoundToOtherPeer = true
recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil)
let handler = makeHandler(recorder: recorder, now: now)
let result = handler.handle(packet, from: peerID)
#expect(result?.isDirectAnnounce == true)
#expect(result?.isVerified == true)
#expect(recorder.upsertCalls.count == 1)
#expect(recorder.upsertCalls.first?.isConnected == false)
}
/// A peer with its own live link stays connected even when a copy of its
/// announce arrives on someone else's link.
@Test
func directAnnounceOnForeignLinkKeepsPeerWithOwnLinkConnected() throws {
let now = Date(timeIntervalSince1970: 1_000)
let noiseKey = Data(repeating: 0x99, count: 32)
let peerID = PeerID(publicKey: noiseKey)
let packet = try makeAnnouncePacket(
noisePublicKey: noiseKey,
peerID: peerID,
timestamp: timestamp(now),
signature: Data(repeating: 0xEE, count: 64)
)
let recorder = Recorder()
recorder.linkBoundToOtherPeer = true
recorder.linkState = (hasPeripheral: false, hasCentral: true)
let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID)
#expect(recorder.upsertCalls.count == 1)
#expect(recorder.upsertCalls.first?.isConnected == true)
}
/// Documents the handler's contract around the rebind: the
/// linkBoundToOtherPeer read reflects the binding BEFORE the rebind runs,
/// so a replayed "direct" announce on someone else's link is denied the
/// connected shortcut — presence only flips via the rebind itself
/// (BLEService promotes the new owner after a successful, containment-
/// checked rebind) or via a later announce arriving on the now-rebound
/// link, as simulated here. Either way the residue is presence display
/// only — DMs remain gated on canDeliverSecurely, so without a Noise
/// session they take the retain + courier path (see MessageRouterTests
/// .sendPrivate_connectedWithoutSecureSessionRetainsAndDepositsWithCourier).
@Test
func secondReplayedDirectAnnounceAfterRebindMarksAbsentPeerConnected() throws {
let now = Date(timeIntervalSince1970: 1_000)
let noiseKey = Data(repeating: 0xA1, count: 32)
let victim = PeerID(publicKey: noiseKey)
let packet = try makeAnnouncePacket(
noisePublicKey: noiseKey,
peerID: victim,
timestamp: timestamp(now),
signature: Data(repeating: 0xEE, count: 64)
)
let recorder = Recorder()
let handler = makeHandler(recorder: recorder, now: now)
// First replay: the link still belongs to the replayer and the victim
// has no live link of its own — the connected shortcut is denied.
recorder.linkBoundToOtherPeer = true
recorder.linkState = (hasPeripheral: false, hasCentral: false)
handler.handle(packet, from: victim)
#expect(recorder.upsertCalls.count == 1)
#expect(recorder.upsertCalls.first?.isConnected == false)
// The rebind then binds the replayer's link to the victim's ID (the
// rotation-heal path, containment-checked in BLEService). A second
// replay now finds the link owned by the claimed peer …
recorder.linkBoundToOtherPeer = false
recorder.linkState = (hasPeripheral: false, hasCentral: true)
handler.handle(packet, from: victim)
// … and the absent victim reads as connected: the residual gap.
#expect(recorder.upsertCalls.count == 2)
#expect(recorder.upsertCalls.last?.isConnected == true)
}
@Test
func announceBackIsSkippedWhenAlreadyMarked() throws {
let now = Date(timeIntervalSince1970: 1_000)
let noiseKey = Data(repeating: 0x77, count: 32)
let peerID = PeerID(publicKey: noiseKey)
let packet = try makeAnnouncePacket(
noisePublicKey: noiseKey,
peerID: peerID,
timestamp: timestamp(now),
signature: Data(repeating: 0xEE, count: 64)
)
let recorder = Recorder()
recorder.dedupSeenIDs = ["announce-back-\(peerID)"]
let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID)
#expect(recorder.dedupContainsQueries == ["announce-back-\(peerID)"])
#expect(recorder.dedupMarkedIDs.isEmpty)
#expect(recorder.announceBacks == 0)
}
@Test
func verifiedAnnounceWithNeighborsUpdatesTopology() throws {
let now = Date(timeIntervalSince1970: 1_000)
let noiseKey = Data(repeating: 0x88, count: 32)
let peerID = PeerID(publicKey: noiseKey)
let neighbors = [Data(repeating: 0xAB, count: 8), Data(repeating: 0xCD, count: 8)]
let packet = try makeAnnouncePacket(
noisePublicKey: noiseKey,
peerID: peerID,
timestamp: timestamp(now),
signature: Data(repeating: 0xEE, count: 64),
directNeighbors: neighbors
)
let recorder = Recorder()
let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID)
#expect(recorder.topologyUpdates.count == 1)
#expect(recorder.topologyUpdates.first?.peerID == peerID)
#expect(recorder.topologyUpdates.first?.neighbors == neighbors)
}
@Test
func matchingPinnedSigningKeyIsAccepted() throws {
let now = Date(timeIntervalSince1970: 1_000)
let noiseKey = Data(repeating: 0x9A, count: 32)
let peerID = PeerID(publicKey: noiseKey)
let packet = try makeAnnouncePacket(
noisePublicKey: noiseKey,
peerID: peerID,
timestamp: timestamp(now),
signature: Data(repeating: 0xEE, count: 64)
)
let recorder = Recorder()
recorder.existingNoisePublicKey = noiseKey
// Matches the signing key encoded by makeAnnouncePacket.
recorder.existingSigningPublicKey = Data(repeating: 0x99, count: 32)
let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID)
#expect(recorder.upsertCalls.count == 1)
#expect(recorder.persistedIdentities.count == 1)
}
@Test
func signingKeyMismatchWithPinnedKeySkipsUpsertAndIdentityPersistence() throws {
let now = Date(timeIntervalSince1970: 1_000)
let noiseKey = Data(repeating: 0x9B, count: 32)
let peerID = PeerID(publicKey: noiseKey)
// Attacker announce: victim's noiseKey/peerID, attacker's signing key
// (0x99 from makeAnnouncePacket) with a "valid" self-signature.
let packet = try makeAnnouncePacket(
noisePublicKey: noiseKey,
peerID: peerID,
timestamp: timestamp(now),
signature: Data(repeating: 0xEE, count: 64)
)
let recorder = Recorder()
recorder.existingNoisePublicKey = noiseKey
recorder.existingSigningPublicKey = Data(repeating: 0x42, count: 32) // victim's pinned key
recorder.signatureValid = true
let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID)
#expect(recorder.upsertCalls.isEmpty)
#expect(recorder.persistedIdentities.isEmpty)
#expect(recorder.topologyUpdates.isEmpty)
#expect(recorder.uiEventDeliveries.count == 1)
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
}
@Test
func persistedSigningKeyMismatchWithoutRegistryEntryIsRejected() throws {
// Registry has no entry (app restart or offline-peer eviction), but
// the persisted cryptographic identity still pins the victim's
// signing key. An attacker replaying the victim's noiseKey/peerID
// with their own signing key must not be treated as first contact.
let now = Date(timeIntervalSince1970: 1_000)
let noiseKey = Data(repeating: 0x9D, count: 32)
let peerID = PeerID(publicKey: noiseKey)
let packet = try makeAnnouncePacket(
noisePublicKey: noiseKey,
peerID: peerID,
timestamp: timestamp(now),
signature: Data(repeating: 0xEE, count: 64)
)
let recorder = Recorder()
recorder.existingNoisePublicKey = nil
recorder.existingSigningPublicKey = nil
recorder.persistedSigningPublicKey = Data(repeating: 0x42, count: 32) // victim's persisted pin
let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID)
#expect(recorder.persistedSigningKeyQueries == [peerID])
#expect(recorder.upsertCalls.isEmpty)
#expect(recorder.persistedIdentities.isEmpty)
#expect(recorder.topologyUpdates.isEmpty)
#expect(recorder.uiEventDeliveries.count == 1)
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
}
@Test
func persistedSigningKeyMatchWithoutRegistryEntryIsAccepted() throws {
// Legitimate returning peer: registry entry evicted, persisted pin
// matches the announced signing key — accepted like a normal announce.
let now = Date(timeIntervalSince1970: 1_000)
let noiseKey = Data(repeating: 0x9E, count: 32)
let peerID = PeerID(publicKey: noiseKey)
let packet = try makeAnnouncePacket(
noisePublicKey: noiseKey,
peerID: peerID,
timestamp: timestamp(now),
signature: Data(repeating: 0xEE, count: 64)
)
let recorder = Recorder()
// Matches the signing key encoded by makeAnnouncePacket.
recorder.persistedSigningPublicKey = Data(repeating: 0x99, count: 32)
let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID)
#expect(recorder.upsertCalls.count == 1)
#expect(recorder.persistedIdentities.count == 1)
}
@Test
func registryPinnedSigningKeySkipsPersistedLookup() throws {
let now = Date(timeIntervalSince1970: 1_000)
let noiseKey = Data(repeating: 0x9F, count: 32)
let peerID = PeerID(publicKey: noiseKey)
let packet = try makeAnnouncePacket(
noisePublicKey: noiseKey,
peerID: peerID,
timestamp: timestamp(now),
signature: Data(repeating: 0xEE, count: 64)
)
let recorder = Recorder()
recorder.existingNoisePublicKey = noiseKey
recorder.existingSigningPublicKey = Data(repeating: 0x99, count: 32)
let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID)
#expect(recorder.persistedSigningKeyQueries.isEmpty)
#expect(recorder.upsertCalls.count == 1)
}
@Test
func registryPinRejectionSkipsTopologyAndIdentityPersistence() throws {
let now = Date(timeIntervalSince1970: 1_000)
let noiseKey = Data(repeating: 0x9C, count: 32)
let peerID = PeerID(publicKey: noiseKey)
let packet = try makeAnnouncePacket(
noisePublicKey: noiseKey,
peerID: peerID,
timestamp: timestamp(now),
signature: Data(repeating: 0xEE, count: 64),
directNeighbors: [Data(repeating: 0xAB, count: 8)]
)
// Pre-barrier trust check sees no pinned key (e.g. concurrent race),
// but the registry itself refuses to replace its pinned signing key.
let recorder = Recorder()
recorder.upsertResult = nil
let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID)
#expect(recorder.upsertCalls.count == 1)
#expect(recorder.persistedIdentities.isEmpty)
#expect(recorder.topologyUpdates.isEmpty)
#expect(recorder.uiEventDeliveries.count == 1)
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
#expect(recorder.afterglowDelays.isEmpty)
}
@Test
func keyMismatchWithExistingPeerKeepsAnnounceUnverified() throws {
let now = Date(timeIntervalSince1970: 1_000)
let noiseKey = Data(repeating: 0x99, count: 32)
let peerID = PeerID(publicKey: noiseKey)
let packet = try makeAnnouncePacket(
noisePublicKey: noiseKey,
peerID: peerID,
timestamp: timestamp(now),
signature: Data(repeating: 0xEE, count: 64)
)
let recorder = Recorder()
recorder.existingNoisePublicKey = Data(repeating: 0xAA, count: 32)
let handler = makeHandler(recorder: recorder, now: now)
let result = handler.handle(packet, from: peerID)
#expect(result?.isVerified == false)
#expect(recorder.upsertCalls.isEmpty)
#expect(recorder.uiEventDeliveries.count == 1)
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
}
@Test
func attackerReplayingVictimNoiseKeyWithOwnSigningKeyIsRejectedEndToEnd() throws {
// Real crypto: the attacker crafts a fully self-consistent announce
// (victim's noiseKey/peerID, attacker's signing key and nickname,
// valid packet signature made with the attacker's key). Without
// signing-key pinning this used to overwrite the victim's registry
// entry and persisted identity.
let victim = NoiseEncryptionService(keychain: MockKeychain())
let attacker = NoiseEncryptionService(keychain: MockKeychain())
let victimNoiseKey = victim.getStaticPublicKeyData()
let peerID = PeerID(publicKey: victimNoiseKey)
let now = Date()
final class RegistryBox {
var registry = BLEPeerRegistry()
var persistedIdentities: [AnnouncementPacket] = []
}
let box = RegistryBox()
let environment = BLEAnnounceHandlerEnvironment(
localPeerID: { PeerID(str: "0102030405060708") },
messageTTL: TransportConfig.messageTTLDefault,
now: { now },
existingPeerKeys: { peerID in
let info = box.registry.info(for: peerID)
return (info?.noisePublicKey, info?.signingPublicKey)
},
persistedSigningPublicKey: { _ in nil },
verifySignature: { packet, signingPublicKey in
victim.verifyPacketSignature(packet, publicKey: signingPublicKey)
},
linkState: { _ in (hasPeripheral: true, hasCentral: false) },
linkBoundToOtherPeer: { _, _ in false },
withRegistryBarrier: { body in body() },
upsertVerifiedAnnounce: { peerID, announcement, isConnected, now in
box.registry.upsertVerifiedAnnounce(
peerID: peerID,
nickname: announcement.nickname,
noisePublicKey: announcement.noisePublicKey,
signingPublicKey: announcement.signingPublicKey,
isConnected: isConnected,
now: now
)
},
shouldEmitReconnectLog: { _, _ in false },
updateTopology: { _, _ in },
persistIdentity: { announcement in
box.persistedIdentities.append(announcement)
},
dedupContains: { _ in true },
dedupMarkProcessed: { _ in },
deliverAnnounceUIEvents: { _, _, _ in },
trackPacketSeen: { _ in },
sendAnnounceBack: {},
scheduleAfterglow: { _ in }
)
let handler = BLEAnnounceHandler(environment: environment)
func makeSignedAnnounce(nickname: String, signer: NoiseEncryptionService) throws -> BitchatPacket {
let announcement = AnnouncementPacket(
nickname: nickname,
noisePublicKey: victimNoiseKey,
signingPublicKey: signer.getSigningPublicKeyData(),
directNeighbors: nil
)
let payload = try #require(announcement.encode())
let packet = BitchatPacket(
type: MessageType.announce.rawValue,
senderID: Data(hexString: peerID.id) ?? Data(),
recipientID: nil,
timestamp: UInt64(now.timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: TransportConfig.messageTTLDefault
)
return try #require(signer.signPacket(packet))
}
// Legitimate announce from the victim is accepted and pinned.
let victimAnnounce = try makeSignedAnnounce(nickname: "victim", signer: victim)
handler.handle(victimAnnounce, from: peerID)
#expect(box.registry.info(for: peerID)?.nickname == "victim")
#expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData())
#expect(box.persistedIdentities.count == 1)
// Attacker announce with a valid self-signature must be rejected.
let attackerAnnounce = try makeSignedAnnounce(nickname: "attacker", signer: attacker)
handler.handle(attackerAnnounce, from: peerID)
#expect(box.registry.info(for: peerID)?.nickname == "victim")
#expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData())
#expect(box.persistedIdentities.count == 1)
// The victim's subsequent announces (same pinned key) still work.
let victimRename = try makeSignedAnnounce(nickname: "victim-renamed", signer: victim)
handler.handle(victimRename, from: peerID)
#expect(box.registry.info(for: peerID)?.nickname == "victim-renamed")
#expect(box.persistedIdentities.count == 2)
}
@Test
func signingKeyPinSurvivesRegistryEvictionAndRestartEndToEnd() throws {
// Real crypto + real persistence: the victim announces and gets
// pinned, then the registry entry disappears (offline-peer eviction
// via reconcileConnectivity, or app restart which starts with an
// empty registry). The attacker replays the victim's
// noiseKey/peerID with their own signing key and a valid
// self-signature — the persisted identity must still block the
// takeover, and must not be overwritten. The victim (same signing
// key) must be re-accepted.
let victim = NoiseEncryptionService(keychain: MockKeychain())
let attacker = NoiseEncryptionService(keychain: MockKeychain())
let victimNoiseKey = victim.getStaticPublicKeyData()
let peerID = PeerID(publicKey: victimNoiseKey)
let now = Date()
let identityKeychain = MockKeychain()
let identityManager = SecureIdentityStateManager(identityKeychain)
final class RegistryBox {
var registry = BLEPeerRegistry()
}
let box = RegistryBox()
func makeEnvironment(identityManager: SecureIdentityStateManager) -> BLEAnnounceHandlerEnvironment {
BLEAnnounceHandlerEnvironment(
localPeerID: { PeerID(str: "0102030405060708") },
messageTTL: TransportConfig.messageTTLDefault,
now: { now },
existingPeerKeys: { peerID in
let info = box.registry.info(for: peerID)
return (info?.noisePublicKey, info?.signingPublicKey)
},
// Mirrors the BLEService wiring: fall back to the persisted
// cryptographic identity.
persistedSigningPublicKey: { peerID in
identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID)
.compactMap { $0.signingPublicKey }
.first
},
verifySignature: { packet, signingPublicKey in
victim.verifyPacketSignature(packet, publicKey: signingPublicKey)
},
linkState: { _ in (hasPeripheral: true, hasCentral: false) },
linkBoundToOtherPeer: { _, _ in false },
withRegistryBarrier: { body in body() },
upsertVerifiedAnnounce: { peerID, announcement, isConnected, now in
box.registry.upsertVerifiedAnnounce(
peerID: peerID,
nickname: announcement.nickname,
noisePublicKey: announcement.noisePublicKey,
signingPublicKey: announcement.signingPublicKey,
isConnected: isConnected,
now: now
)
},
shouldEmitReconnectLog: { _, _ in false },
updateTopology: { _, _ in },
persistIdentity: { announcement in
identityManager.upsertCryptographicIdentity(
fingerprint: announcement.noisePublicKey.sha256Fingerprint(),
noisePublicKey: announcement.noisePublicKey,
signingPublicKey: announcement.signingPublicKey,
claimedNickname: announcement.nickname
)
},
dedupContains: { _ in true },
dedupMarkProcessed: { _ in },
deliverAnnounceUIEvents: { _, _, _ in },
trackPacketSeen: { _ in },
sendAnnounceBack: {},
scheduleAfterglow: { _ in }
)
}
let handler = BLEAnnounceHandler(environment: makeEnvironment(identityManager: identityManager))
func makeSignedAnnounce(nickname: String, signer: NoiseEncryptionService) throws -> BitchatPacket {
let announcement = AnnouncementPacket(
nickname: nickname,
noisePublicKey: victimNoiseKey,
signingPublicKey: signer.getSigningPublicKeyData(),
directNeighbors: nil
)
let payload = try #require(announcement.encode())
let packet = BitchatPacket(
type: MessageType.announce.rawValue,
senderID: Data(hexString: peerID.id) ?? Data(),
recipientID: nil,
timestamp: UInt64(now.timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: TransportConfig.messageTTLDefault
)
return try #require(signer.signPacket(packet))
}
func persistedIdentity() -> CryptographicIdentity? {
// queue.sync read; fences the manager's pending barrier writes.
identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID).first
}
// 1. Victim announces: pinned in the registry and persisted.
handler.handle(try makeSignedAnnounce(nickname: "victim", signer: victim), from: peerID)
#expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData())
#expect(persistedIdentity()?.signingPublicKey == victim.getSigningPublicKeyData())
// 2. Registry entry disappears (eviction / restart).
_ = box.registry.remove(peerID)
#expect(box.registry.info(for: peerID) == nil)
// 3. Attacker replay with own signing key: rejected via the persisted
// pin, and neither the registry nor the persisted identity change.
handler.handle(try makeSignedAnnounce(nickname: "attacker", signer: attacker), from: peerID)
#expect(box.registry.info(for: peerID) == nil)
#expect(persistedIdentity()?.signingPublicKey == victim.getSigningPublicKeyData())
#expect(identityManager.getSocialIdentity(for: victimNoiseKey.sha256Fingerprint())?.claimedNickname == "victim")
// 4. Victim re-announces with the same signing key: accepted again.
handler.handle(try makeSignedAnnounce(nickname: "victim", signer: victim), from: peerID)
#expect(box.registry.info(for: peerID)?.nickname == "victim")
#expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData())
// 5. Simulated app restart: a fresh identity manager reloads the pin
// from the (mock) keychain, and a fresh registry starts empty. The
// attacker replay is still rejected.
identityManager.forceSave()
let reloadedManager = SecureIdentityStateManager(identityKeychain)
#expect(
reloadedManager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey
== victim.getSigningPublicKeyData()
)
box.registry = BLEPeerRegistry()
let restartedHandler = BLEAnnounceHandler(environment: makeEnvironment(identityManager: reloadedManager))
restartedHandler.handle(try makeSignedAnnounce(nickname: "attacker", signer: attacker), from: peerID)
#expect(box.registry.info(for: peerID) == nil)
#expect(
reloadedManager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey
== victim.getSigningPublicKeyData()
)
// ...while the victim is accepted after the restart.
restartedHandler.handle(try makeSignedAnnounce(nickname: "victim", signer: victim), from: peerID)
#expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData())
}
private func expectNoSideEffects(_ recorder: Recorder) {
#expect(recorder.barrierCount == 0)
#expect(recorder.upsertCalls.isEmpty)
#expect(recorder.topologyUpdates.isEmpty)
#expect(recorder.persistedIdentities.isEmpty)
#expect(recorder.dedupMarkedIDs.isEmpty)
#expect(recorder.uiEventDeliveries.isEmpty)
#expect(recorder.trackedPackets.isEmpty)
#expect(recorder.announceBacks == 0)
#expect(recorder.afterglowDelays.isEmpty)
}
private func makeAnnouncePacket(
noisePublicKey: Data,
peerID: PeerID,
timestamp: UInt64,
signature: Data?,
ttl: UInt8 = TransportConfig.messageTTLDefault,
directNeighbors: [Data]? = nil
) throws -> BitchatPacket {
let announcement = AnnouncementPacket(
nickname: "Alice",
noisePublicKey: noisePublicKey,
signingPublicKey: Data(repeating: 0x99, count: 32),
directNeighbors: directNeighbors
)
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: signature,
ttl: ttl
)
}
private func timestamp(_ date: Date) -> UInt64 {
UInt64(date.timeIntervalSince1970 * 1000)
}
}