From 385d32063f9720a02fdfaa927c902dd484bad412 Mon Sep 17 00:00:00 2001 From: jack Date: Wed, 1 Jul 2026 23:31:09 +0200 Subject: [PATCH] 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; 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 --- bitchat/Services/BLE/BLEAnnounceHandler.swift | 36 +++- .../BLE/BLEAnnounceHandlingPolicy.swift | 16 +- bitchat/Services/BLE/BLEPeerRegistry.swift | 20 +- bitchat/Services/BLE/BLEService.swift | 16 +- .../Services/BLEAnnounceHandlerTests.swift | 187 +++++++++++++++++- .../BLEAnnounceHandlingPolicyTests.swift | 52 ++++- .../Services/BLEPeerRegistryTests.swift | 87 +++++++- 7 files changed, 388 insertions(+), 26 deletions(-) diff --git a/bitchat/Services/BLE/BLEAnnounceHandler.swift b/bitchat/Services/BLE/BLEAnnounceHandler.swift index fbd594a2..47664901 100644 --- a/bitchat/Services/BLE/BLEAnnounceHandler.swift +++ b/bitchat/Services/BLE/BLEAnnounceHandler.swift @@ -14,8 +14,9 @@ struct BLEAnnounceHandlerEnvironment { let messageTTL: UInt8 /// Current time source. let now: () -> Date - /// Noise public key already recorded for the peer, if any (registry read). - let existingNoisePublicKey: (PeerID) -> Data? + /// Noise and signing public keys already recorded for the peer, if any + /// (single registry read so both come from one consistent snapshot). + let existingPeerKeys: (PeerID) -> (noisePublicKey: Data?, signingPublicKey: Data?) /// Verifies the packet signature against the announced signing key. let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool /// Direct link state for the peer (BLE-queue read). @@ -23,13 +24,15 @@ struct BLEAnnounceHandlerEnvironment { /// Runs the registry mutation phase under the collections barrier. let withRegistryBarrier: (() -> Void) -> Void /// Upserts the verified announce into the peer registry. + /// Returns `nil` when the registry refuses the announce because it carries + /// a signing key different from the one already pinned for this peer. /// Must only be called from inside `withRegistryBarrier`. let upsertVerifiedAnnounce: ( _ peerID: PeerID, _ announcement: AnnouncementPacket, _ isConnected: Bool, _ now: Date - ) -> BLEPeerAnnounceUpdate + ) -> BLEPeerAnnounceUpdate? /// Debounced reconnect-log decision. /// Must only be called from inside `withRegistryBarrier`. let shouldEmitReconnectLog: (_ peerID: PeerID, _ now: Date) -> Bool @@ -99,7 +102,7 @@ final class BLEAnnounceHandler { // Suppress announce logs to reduce noise // Precompute signature verification outside barrier to reduce contention - let existingNoisePublicKey = env.existingNoisePublicKey(peerID) + let existingPeerKeys = env.existingPeerKeys(peerID) let hasSignature = packet.signature != nil let signatureValid: Bool if hasSignature { @@ -113,13 +116,18 @@ final class BLEAnnounceHandler { let trustDecision = BLEAnnounceTrustPolicy.evaluate( hasSignature: hasSignature, signatureValid: signatureValid, - existingNoisePublicKey: existingNoisePublicKey, - announcedNoisePublicKey: announcement.noisePublicKey + existingNoisePublicKey: existingPeerKeys.noisePublicKey, + announcedNoisePublicKey: announcement.noisePublicKey, + existingSigningPublicKey: existingPeerKeys.signingPublicKey, + announcedSigningPublicKey: announcement.signingPublicKey ) if case .reject(.keyMismatch) = trustDecision { SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security) } - let verifiedAnnounce = trustDecision.isVerified + if case .reject(.signingKeyMismatch) = trustDecision { + SecureLogger.warning("🚨 Announce signing-key mismatch for \(peerID.id.prefix(8))… — refusing to replace pinned signing key (possible impersonation attempt)", category: .security) + } + var verifiedAnnounce = trustDecision.isVerified var isNewPeer = false var isReconnectedPeer = false @@ -139,12 +147,22 @@ final class BLEAnnounceHandler { return } - let update = env.upsertVerifiedAnnounce( + // The registry re-checks the signing-key pin inside the barrier. + // The pre-barrier trust check reads the registry outside the + // barrier, so this closes the race where two announces for the + // same peer are evaluated concurrently. + guard let update = env.upsertVerifiedAnnounce( peerID, announcement, isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription, now - ) + ) else { + SecureLogger.warning("🚨 Registry refused announce for \(peerID.id.prefix(8))… — signing key differs from pinned key", category: .security) + verifiedAnnounce = false + isNewPeer = false + isReconnectedPeer = false + return + } isNewPeer = update.isNewPeer isReconnectedPeer = update.wasDisconnected diff --git a/bitchat/Services/BLE/BLEAnnounceHandlingPolicy.swift b/bitchat/Services/BLE/BLEAnnounceHandlingPolicy.swift index 9e81d9ce..03c80325 100644 --- a/bitchat/Services/BLE/BLEAnnounceHandlingPolicy.swift +++ b/bitchat/Services/BLE/BLEAnnounceHandlingPolicy.swift @@ -56,6 +56,7 @@ enum BLEAnnounceTrustRejection: Equatable { case missingSignature case invalidSignature case keyMismatch + case signingKeyMismatch } enum BLEAnnounceTrustDecision: Equatable { @@ -72,12 +73,25 @@ enum BLEAnnounceTrustPolicy { hasSignature: Bool, signatureValid: Bool, existingNoisePublicKey: Data?, - announcedNoisePublicKey: Data + announcedNoisePublicKey: Data, + existingSigningPublicKey: Data?, + announcedSigningPublicKey: Data ) -> BLEAnnounceTrustDecision { if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey { return .reject(.keyMismatch) } + // TOFU signing-key pinning. The packet signature only proves the + // announce is self-consistent — it is verified against the Ed25519 key + // carried *inside the same announce*. Since peerIDs derive from the + // broadcast (public) noise key, an attacker can replay a victim's + // peerID+noiseKey with their own signing key and a valid + // self-signature. Once we have bound a signing key to this peer, + // refuse to silently replace it. + if let existingSigningPublicKey, existingSigningPublicKey != announcedSigningPublicKey { + return .reject(.signingKeyMismatch) + } + guard hasSignature else { return .reject(.missingSignature) } diff --git a/bitchat/Services/BLE/BLEPeerRegistry.swift b/bitchat/Services/BLE/BLEPeerRegistry.swift index 78e9591d..bbe798a5 100644 --- a/bitchat/Services/BLE/BLEPeerRegistry.swift +++ b/bitchat/Services/BLE/BLEPeerRegistry.swift @@ -150,6 +150,14 @@ struct BLEPeerRegistry { peers[peerID] = peer } + /// Applies a verified announce to the registry. + /// + /// TOFU signing-key pinning: once a signing key has been bound to this + /// peer entry, an announce carrying a *different* signing key is refused + /// (returns `nil`) and the existing record is left untouched. PeerIDs are + /// derived from the (public) noise key, so without pinning an attacker + /// could replay a victim's noiseKey/peerID with their own signing key and + /// silently take over the victim's mesh identity and nickname. mutating func upsertVerifiedAnnounce( peerID: PeerID, nickname: String, @@ -157,8 +165,15 @@ struct BLEPeerRegistry { signingPublicKey: Data?, isConnected: Bool, now: Date - ) -> BLEPeerAnnounceUpdate { + ) -> BLEPeerAnnounceUpdate? { let existing = peers[peerID] + + if let pinnedSigningKey = existing?.signingPublicKey, + let announcedSigningKey = signingPublicKey, + pinnedSigningKey != announcedSigningKey { + return nil + } + let update = BLEPeerAnnounceUpdate( isNewPeer: existing == nil, wasDisconnected: existing?.isConnected == false, @@ -170,7 +185,8 @@ struct BLEPeerRegistry { nickname: nickname, isConnected: isConnected, noisePublicKey: noisePublicKey, - signingPublicKey: signingPublicKey, + // Never drop an already-pinned signing key. + signingPublicKey: signingPublicKey ?? existing?.signingPublicKey, isVerifiedNickname: true, lastSeen: now ) diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 2ee9cd13..546b86c0 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -3028,9 +3028,12 @@ extension BLEService { }, messageTTL: messageTTL, now: { Date() }, - existingNoisePublicKey: { [weak self] peerID in - guard let self = self else { return nil } - return self.collectionsQueue.sync { self.peerRegistry.info(for: peerID)?.noisePublicKey } + existingPeerKeys: { [weak self] peerID in + guard let self = self else { return (nil, nil) } + return self.collectionsQueue.sync { + let info = self.peerRegistry.info(for: peerID) + return (info?.noisePublicKey, info?.signingPublicKey) + } }, verifySignature: { [weak self] packet, signingPublicKey in self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false @@ -3043,14 +3046,17 @@ extension BLEService { }, upsertVerifiedAnnounce: { [weak self] peerID, announcement, isConnected, now in // Called from inside withRegistryBarrier; access registry directly. - self?.peerRegistry.upsertVerifiedAnnounce( + guard let self = self else { + return BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil) + } + return self.peerRegistry.upsertVerifiedAnnounce( peerID: peerID, nickname: announcement.nickname, noisePublicKey: announcement.noisePublicKey, signingPublicKey: announcement.signingPublicKey, isConnected: isConnected, now: now - ) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil) + ) }, shouldEmitReconnectLog: { [weak self] peerID, now in // Called from inside withRegistryBarrier; access debouncer directly. diff --git a/bitchatTests/Services/BLEAnnounceHandlerTests.swift b/bitchatTests/Services/BLEAnnounceHandlerTests.swift index 80944ed4..9f28b41c 100644 --- a/bitchatTests/Services/BLEAnnounceHandlerTests.swift +++ b/bitchatTests/Services/BLEAnnounceHandlerTests.swift @@ -6,9 +6,10 @@ import Testing struct BLEAnnounceHandlerTests { private final class Recorder { var existingNoisePublicKey: Data? + var existingSigningPublicKey: Data? var signatureValid = true var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false) - var upsertResult = BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil) + var upsertResult: BLEPeerAnnounceUpdate? = BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil) var dedupSeenIDs: Set = [] var shouldEmitReconnectLogResult = true @@ -35,7 +36,7 @@ struct BLEAnnounceHandlerTests { localPeerID: { localPeerID }, messageTTL: TransportConfig.messageTTLDefault, now: { now }, - existingNoisePublicKey: { _ in recorder.existingNoisePublicKey }, + existingPeerKeys: { _ in (recorder.existingNoisePublicKey, recorder.existingSigningPublicKey) }, verifySignature: { packet, signingPublicKey in recorder.verifySignatureCalls.append((packet, signingPublicKey)) return recorder.signatureValid @@ -368,6 +369,88 @@ struct BLEAnnounceHandlerTests { #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 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) @@ -391,6 +474,106 @@ struct BLEAnnounceHandlerTests { #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) + }, + verifySignature: { packet, signingPublicKey in + victim.verifyPacketSignature(packet, publicKey: signingPublicKey) + }, + linkState: { _ in (hasPeripheral: true, hasCentral: 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) + } + private func expectNoSideEffects(_ recorder: Recorder) { #expect(recorder.barrierCount == 0) #expect(recorder.upsertCalls.isEmpty) diff --git a/bitchatTests/Services/BLEAnnounceHandlingPolicyTests.swift b/bitchatTests/Services/BLEAnnounceHandlingPolicyTests.swift index c0226b81..8004cc48 100644 --- a/bitchatTests/Services/BLEAnnounceHandlingPolicyTests.swift +++ b/bitchatTests/Services/BLEAnnounceHandlingPolicyTests.swift @@ -123,7 +123,9 @@ struct BLEAnnounceHandlingPolicyTests { hasSignature: false, signatureValid: false, existingNoisePublicKey: nil, - announcedNoisePublicKey: Data(repeating: 0x11, count: 32) + announcedNoisePublicKey: Data(repeating: 0x11, count: 32), + existingSigningPublicKey: nil, + announcedSigningPublicKey: Data(repeating: 0x99, count: 32) ) #expect(decision == .reject(.missingSignature)) @@ -136,7 +138,9 @@ struct BLEAnnounceHandlingPolicyTests { hasSignature: true, signatureValid: false, existingNoisePublicKey: nil, - announcedNoisePublicKey: Data(repeating: 0x11, count: 32) + announcedNoisePublicKey: Data(repeating: 0x11, count: 32), + existingSigningPublicKey: nil, + announcedSigningPublicKey: Data(repeating: 0x99, count: 32) ) #expect(decision == .reject(.invalidSignature)) @@ -148,7 +152,9 @@ struct BLEAnnounceHandlingPolicyTests { hasSignature: true, signatureValid: true, existingNoisePublicKey: Data(repeating: 0xAA, count: 32), - announcedNoisePublicKey: Data(repeating: 0xBB, count: 32) + announcedNoisePublicKey: Data(repeating: 0xBB, count: 32), + existingSigningPublicKey: nil, + announcedSigningPublicKey: Data(repeating: 0x99, count: 32) ) #expect(decision == .reject(.keyMismatch)) @@ -162,13 +168,51 @@ struct BLEAnnounceHandlingPolicyTests { hasSignature: true, signatureValid: true, existingNoisePublicKey: noiseKey, - announcedNoisePublicKey: 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( diff --git a/bitchatTests/Services/BLEPeerRegistryTests.swift b/bitchatTests/Services/BLEPeerRegistryTests.swift index 01b69eae..2a8188d4 100644 --- a/bitchatTests/Services/BLEPeerRegistryTests.swift +++ b/bitchatTests/Services/BLEPeerRegistryTests.swift @@ -6,12 +6,12 @@ import Testing @Suite("BLE peer registry tests") struct BLEPeerRegistryTests { @Test("upserted announces track new, reconnect, and rename transitions") - func upsertVerifiedAnnounceTracksTransitions() { + func upsertVerifiedAnnounceTracksTransitions() throws { var registry = BLEPeerRegistry() let peerID = PeerID(str: "1122334455667788") let firstSeen = Date(timeIntervalSince1970: 100) - let first = registry.upsertVerifiedAnnounce( + let firstResult = registry.upsertVerifiedAnnounce( peerID: peerID, nickname: "alice", noisePublicKey: Data([1, 2, 3]), @@ -19,6 +19,7 @@ struct BLEPeerRegistryTests { isConnected: true, now: firstSeen ) + let first = try #require(firstResult) #expect(first.isNewPeer) #expect(!first.wasDisconnected) @@ -27,7 +28,7 @@ struct BLEPeerRegistryTests { #expect(registry.nickname(for: peerID, connectedOnly: true) == "alice") registry.markDisconnected(peerID) - let reconnect = registry.upsertVerifiedAnnounce( + let reconnectResult = registry.upsertVerifiedAnnounce( peerID: peerID, nickname: "alice-renamed", noisePublicKey: Data([1, 2, 3]), @@ -35,6 +36,7 @@ struct BLEPeerRegistryTests { isConnected: true, now: firstSeen.addingTimeInterval(1) ) + let reconnect = try #require(reconnectResult) #expect(!reconnect.isNewPeer) #expect(reconnect.wasDisconnected) @@ -42,6 +44,85 @@ struct BLEPeerRegistryTests { #expect(registry.info(for: peerID)?.nickname == "alice-renamed") } + @Test("pinned signing key cannot be silently replaced by a later announce") + func upsertVerifiedAnnounceRefusesToReplacePinnedSigningKey() throws { + var registry = BLEPeerRegistry() + let peerID = PeerID(str: "1122334455667788") + let noiseKey = Data(repeating: 0x11, count: 32) + let victimSigningKey = Data(repeating: 0x42, count: 32) + let attackerSigningKey = Data(repeating: 0x66, count: 32) + let firstSeen = Date(timeIntervalSince1970: 100) + + let pinResult = registry.upsertVerifiedAnnounce( + peerID: peerID, + nickname: "victim", + noisePublicKey: noiseKey, + signingPublicKey: victimSigningKey, + isConnected: true, + now: firstSeen + ) + #expect(pinResult != nil) + + // Attacker replays the victim's noiseKey/peerID with their own + // signing key and nickname; the upsert must be refused wholesale. + let attack = registry.upsertVerifiedAnnounce( + peerID: peerID, + nickname: "attacker", + noisePublicKey: noiseKey, + signingPublicKey: attackerSigningKey, + isConnected: true, + now: firstSeen.addingTimeInterval(1) + ) + + #expect(attack == nil) + let info = try #require(registry.info(for: peerID)) + #expect(info.nickname == "victim") + #expect(info.signingPublicKey == victimSigningKey) + + // A legitimate re-announce with the pinned key is still accepted. + let legit = registry.upsertVerifiedAnnounce( + peerID: peerID, + nickname: "victim-renamed", + noisePublicKey: noiseKey, + signingPublicKey: victimSigningKey, + isConnected: true, + now: firstSeen.addingTimeInterval(2) + ) + #expect(legit != nil) + #expect(registry.info(for: peerID)?.nickname == "victim-renamed") + } + + @Test("announce without a signing key keeps the pinned key") + func upsertVerifiedAnnounceKeepsPinnedSigningKeyWhenAnnounceOmitsIt() throws { + var registry = BLEPeerRegistry() + let peerID = PeerID(str: "1122334455667788") + let noiseKey = Data(repeating: 0x11, count: 32) + let signingKey = Data(repeating: 0x42, count: 32) + let firstSeen = Date(timeIntervalSince1970: 100) + + let initialResult = registry.upsertVerifiedAnnounce( + peerID: peerID, + nickname: "alice", + noisePublicKey: noiseKey, + signingPublicKey: signingKey, + isConnected: true, + now: firstSeen + ) + #expect(initialResult != nil) + + let update = registry.upsertVerifiedAnnounce( + peerID: peerID, + nickname: "alice", + noisePublicKey: noiseKey, + signingPublicKey: nil, + isConnected: true, + now: firstSeen.addingTimeInterval(1) + ) + + #expect(update != nil) + #expect(registry.info(for: peerID)?.signingPublicKey == signingKey) + } + @Test("reachability keeps recent verified offline peers only when mesh is attached") func reachabilityRequiresMeshAttachmentForOfflinePeers() { let offlinePeer = PeerID(str: "1122334455667788")