diff --git a/bitchat/Identity/IdentityModels.swift b/bitchat/Identity/IdentityModels.swift index 29f036c6..d92a65e5 100644 --- a/bitchat/Identity/IdentityModels.swift +++ b/bitchat/Identity/IdentityModels.swift @@ -141,22 +141,44 @@ enum TrustLevel: String, Codable { struct IdentityCache: Codable { // Fingerprint -> Social mapping var socialIdentities: [String: SocialIdentity] = [:] - + // Nickname -> [Fingerprints] reverse index // Multiple fingerprints can claim same nickname var nicknameIndex: [String: Set] = [:] - + // Verified fingerprints (cryptographic proof) var verifiedFingerprints: Set = [] - + // Last interaction timestamps (privacy: optional) - var lastInteractions: [String: Date] = [:] - + var lastInteractions: [String: Date] = [:] + // Blocked Nostr pubkeys (lowercased hex) for geohash chats var blockedNostrPubkeys: Set = [] - + + // Fingerprint -> Cryptographic identity (noise + pinned signing key). + // Persisting the signing-key pin is security-critical: it must survive + // app restarts so an attacker cannot replay a known peer's + // noiseKey/peerID with their own signing key and be treated as first + // contact (TOFU downgrade). + var cryptographicIdentities: [String: CryptographicIdentity] = [:] + // Schema version for future migrations var version: Int = 1 + + init() {} + + // Custom decoding so caches written by older builds (without + // `cryptographicIdentities`) still load instead of being discarded. + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + socialIdentities = try container.decodeIfPresent([String: SocialIdentity].self, forKey: .socialIdentities) ?? [:] + nicknameIndex = try container.decodeIfPresent([String: Set].self, forKey: .nicknameIndex) ?? [:] + verifiedFingerprints = try container.decodeIfPresent(Set.self, forKey: .verifiedFingerprints) ?? [] + lastInteractions = try container.decodeIfPresent([String: Date].self, forKey: .lastInteractions) ?? [:] + blockedNostrPubkeys = try container.decodeIfPresent(Set.self, forKey: .blockedNostrPubkeys) ?? [] + cryptographicIdentities = try container.decodeIfPresent([String: CryptographicIdentity].self, forKey: .cryptographicIdentities) ?? [:] + version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1 + } } // diff --git a/bitchat/Identity/SecureIdentityStateManager.swift b/bitchat/Identity/SecureIdentityStateManager.swift index fe63f871..1531d3d4 100644 --- a/bitchat/Identity/SecureIdentityStateManager.swift +++ b/bitchat/Identity/SecureIdentityStateManager.swift @@ -145,7 +145,8 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { // In-memory state private var ephemeralSessions: [PeerID: EphemeralIdentity] = [:] - private var cryptographicIdentities: [String: CryptographicIdentity] = [:] + // Cryptographic identities (including pinned signing keys) live inside + // `cache` so they persist across app restarts; see IdentityCache. private var cache: IdentityCache = IdentityCache() // Thread safety @@ -296,6 +297,15 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { // MARK: - Cryptographic Identities /// Insert or update a cryptographic identity and optionally persist its signing key and claimed nickname. + /// + /// TOFU signing-key pinning: once a signing key has been persisted for a + /// fingerprint, an update carrying a *different* signing key is refused in + /// full (including the claimed-nickname update) and security-logged. This + /// mirrors `BLEPeerRegistry.upsertVerifiedAnnounce` — without it, an + /// attacker replaying a victim's noiseKey/peerID with their own signing + /// key could overwrite the victim's persisted identity while the victim is + /// offline or after an app restart. Recovering from a legitimate signing + /// re-key requires a new noise identity or explicit user re-verification. /// - Parameters: /// - fingerprint: SHA-256 hex of the Noise static public key /// - noisePublicKey: Noise static public key data @@ -304,7 +314,13 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String? = nil) { queue.async(flags: .barrier) { let now = Date() - if var existing = self.cryptographicIdentities[fingerprint] { + if var existing = self.cache.cryptographicIdentities[fingerprint] { + if let pinnedSigningKey = existing.signingPublicKey, + let announcedSigningKey = signingPublicKey, + pinnedSigningKey != announcedSigningKey { + SecureLogger.warning("🚨 Refusing to replace pinned signing key for \(fingerprint.prefix(8))… (possible impersonation attempt)", category: .security) + return + } // Update keys if changed if existing.publicKey != noisePublicKey { existing = CryptographicIdentity( @@ -314,7 +330,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { firstSeen: existing.firstSeen, lastHandshake: now ) - self.cryptographicIdentities[fingerprint] = existing + self.cache.cryptographicIdentities[fingerprint] = existing } else { // Update signing key and lastHandshake existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey @@ -325,7 +341,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { firstSeen: existing.firstSeen, lastHandshake: now ) - self.cryptographicIdentities[fingerprint] = updated + self.cache.cryptographicIdentities[fingerprint] = updated } // Persist updated state (already assigned in branches above) } else { @@ -337,7 +353,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { firstSeen: now, lastHandshake: now ) - self.cryptographicIdentities[fingerprint] = entry + self.cache.cryptographicIdentities[fingerprint] = entry } // Optionally persist claimed nickname into social identity @@ -369,7 +385,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { queue.sync { // Defensive: ensure hex and correct length guard peerID.isShort else { return [] } - return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) } + return cache.cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) } } } @@ -528,8 +544,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { queue.async(flags: .barrier) { self.cache = IdentityCache() self.ephemeralSessions.removeAll() - self.cryptographicIdentities.removeAll() - + // Delete from keychain let deleted = self.keychain.deleteIdentityKey(forKey: self.cacheKey) SecureLogger.logKeyOperation(.delete, keyType: "identity cache", success: deleted) diff --git a/bitchat/Services/BLE/BLEAnnounceHandler.swift b/bitchat/Services/BLE/BLEAnnounceHandler.swift index 47664901..e1ec3b0e 100644 --- a/bitchat/Services/BLE/BLEAnnounceHandler.swift +++ b/bitchat/Services/BLE/BLEAnnounceHandler.swift @@ -17,6 +17,11 @@ struct BLEAnnounceHandlerEnvironment { /// 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?) + /// Signing key from the persisted cryptographic identity for the peer, if + /// any. Registry pins do not survive app restarts or offline-peer + /// eviction; this fallback keeps the TOFU signing-key pin effective for + /// returning peers. + let persistedSigningPublicKey: (PeerID) -> 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). @@ -102,7 +107,16 @@ final class BLEAnnounceHandler { // Suppress announce logs to reduce noise // Precompute signature verification outside barrier to reduce contention - let existingPeerKeys = env.existingPeerKeys(peerID) + var existingPeerKeys = env.existingPeerKeys(peerID) + if existingPeerKeys.signingPublicKey == nil { + // The registry entry (and its signing-key pin) is dropped on app + // restart and offline-peer eviction, but the persisted + // cryptographic identity survives both. Fall back to it so a + // returning peer is not treated as first contact — otherwise an + // attacker could replay the peer's noiseKey/peerID with their own + // signing key and re-pin the identity (TOFU downgrade). + existingPeerKeys.signingPublicKey = env.persistedSigningPublicKey(peerID) + } let hasSignature = packet.signature != nil let signatureValid: Bool if hasSignature { diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 546b86c0..74be0b93 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -3035,6 +3035,15 @@ extension BLEService { return (info?.noisePublicKey, info?.signingPublicKey) } }, + persistedSigningPublicKey: { [weak self] peerID in + // Same synchronous identity-manager read pattern as + // signedSenderDisplayName(for:from:); the manager serializes + // access on its own internal queue. + guard let self = self else { return nil } + return self.identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID) + .compactMap { $0.signingPublicKey } + .first + }, verifySignature: { [weak self] packet, signingPublicKey in self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false }, diff --git a/bitchatTests/Services/BLEAnnounceHandlerTests.swift b/bitchatTests/Services/BLEAnnounceHandlerTests.swift index 9f28b41c..292c00f4 100644 --- a/bitchatTests/Services/BLEAnnounceHandlerTests.swift +++ b/bitchatTests/Services/BLEAnnounceHandlerTests.swift @@ -7,6 +7,8 @@ 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 upsertResult: BLEPeerAnnounceUpdate? = BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil) @@ -37,6 +39,10 @@ struct BLEAnnounceHandlerTests { 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 @@ -422,6 +428,86 @@ struct BLEAnnounceHandlerTests { #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) @@ -501,6 +587,7 @@ struct BLEAnnounceHandlerTests { let info = box.registry.info(for: peerID) return (info?.noisePublicKey, info?.signingPublicKey) }, + persistedSigningPublicKey: { _ in nil }, verifySignature: { packet, signingPublicKey in victim.verifyPacketSignature(packet, publicKey: signingPublicKey) }, @@ -574,6 +661,150 @@ struct BLEAnnounceHandlerTests { #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) }, + 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) diff --git a/bitchatTests/Services/SecureIdentityStateManagerTests.swift b/bitchatTests/Services/SecureIdentityStateManagerTests.swift index 27e57fdf..9d575d4e 100644 --- a/bitchatTests/Services/SecureIdentityStateManagerTests.swift +++ b/bitchatTests/Services/SecureIdentityStateManagerTests.swift @@ -79,6 +79,100 @@ final class SecureIdentityStateManagerTests: XCTestCase { XCTAssertEqual(matches.first?.signingPublicKey, signingPublicKey) } + func test_upsertCryptographicIdentity_refusesToReplacePinnedSigningKey() async { + let manager = SecureIdentityStateManager(MockKeychain()) + let noisePublicKey = Data(repeating: 0x11, count: 32) + let fingerprint = noisePublicKey.sha256Fingerprint() + let peerID = PeerID(publicKey: noisePublicKey) + let victimSigningKey = Data(repeating: 0x22, count: 32) + let attackerSigningKey = Data(repeating: 0x66, count: 32) + + manager.upsertCryptographicIdentity( + fingerprint: fingerprint, + noisePublicKey: noisePublicKey, + signingPublicKey: victimSigningKey, + claimedNickname: "victim" + ) + let pinned = await waitUntil { + manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey == victimSigningKey + } + XCTAssertTrue(pinned) + + // Attacker upsert with a different signing key must be refused in + // full — signing key AND claimed nickname stay the victim's. + manager.upsertCryptographicIdentity( + fingerprint: fingerprint, + noisePublicKey: noisePublicKey, + signingPublicKey: attackerSigningKey, + claimedNickname: "attacker" + ) + + // Synchronous reads fence the manager's pending barrier writes. + XCTAssertEqual( + manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey, + victimSigningKey + ) + XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.claimedNickname, "victim") + + // The legitimate peer (same signing key) can still update. + manager.upsertCryptographicIdentity( + fingerprint: fingerprint, + noisePublicKey: noisePublicKey, + signingPublicKey: victimSigningKey, + claimedNickname: "victim-renamed" + ) + let renamed = await waitUntil { + manager.getSocialIdentity(for: fingerprint)?.claimedNickname == "victim-renamed" + } + XCTAssertTrue(renamed) + XCTAssertEqual( + manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey, + victimSigningKey + ) + } + + func test_cryptographicIdentity_persistsAcrossReinitAndKeepsSigningKeyPin() async { + let keychain = MockKeychain() + let manager = SecureIdentityStateManager(keychain) + let noisePublicKey = Data(repeating: 0x13, count: 32) + let fingerprint = noisePublicKey.sha256Fingerprint() + let peerID = PeerID(publicKey: noisePublicKey) + let victimSigningKey = Data(repeating: 0x24, count: 32) + let attackerSigningKey = Data(repeating: 0x77, count: 32) + + manager.upsertCryptographicIdentity( + fingerprint: fingerprint, + noisePublicKey: noisePublicKey, + signingPublicKey: victimSigningKey, + claimedNickname: "victim" + ) + let pinned = await waitUntil { + manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey == victimSigningKey + } + XCTAssertTrue(pinned) + manager.forceSave() + + // Simulated app restart: the pin must survive and still refuse a + // different signing key. + let reloaded = SecureIdentityStateManager(keychain) + XCTAssertEqual( + reloaded.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey, + victimSigningKey + ) + + reloaded.upsertCryptographicIdentity( + fingerprint: fingerprint, + noisePublicKey: noisePublicKey, + signingPublicKey: attackerSigningKey, + claimedNickname: "attacker" + ) + XCTAssertEqual( + reloaded.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey, + victimSigningKey + ) + XCTAssertEqual(reloaded.getSocialIdentity(for: fingerprint)?.claimedNickname, "victim") + } + func test_setBlocked_clearsFavoriteState() async { let manager = SecureIdentityStateManager(MockKeychain()) let fingerprint = String(repeating: "ab", count: 32)