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