diff --git a/bitchat/App/ConversationUIModel.swift b/bitchat/App/ConversationUIModel.swift index d1cc595f..508ca493 100644 --- a/bitchat/App/ConversationUIModel.swift +++ b/bitchat/App/ConversationUIModel.swift @@ -165,10 +165,9 @@ final class ConversationUIModel: ObservableObject { let peerID = message.senderPeerID else { return false } guard let fingerprint = chatViewModel.getFingerprint(for: peerID) else { return false } guard chatViewModel.peerIdentityStore.isVerified(fingerprint) else { return false } - // The seal is read next to `message.sender`, so it has to be bound to - // that name: a verified key that renamed itself onto a name the user - // trusts must not carry the seal beside the new one. - return !chatViewModel.trustedNicknameMismatch(fingerprint, displayedSender: message.sender) + // A verified key that renamed itself onto a name the user trusts must + // not carry the seal beside the new one. + return !chatViewModel.trustedNicknameMismatch(fingerprint) } func senderDisplayName(for peerID: PeerID, fallbackMessages: [BitchatMessage]) -> String? { diff --git a/bitchat/App/PeerListModel.swift b/bitchat/App/PeerListModel.swift index 779bff26..4cf8bb51 100644 --- a/bitchat/App/PeerListModel.swift +++ b/bitchat/App/PeerListModel.swift @@ -234,13 +234,15 @@ final class PeerListModel: ObservableObject { let isVerifiedFingerprint = fingerprint.map { peerIdentityStore.isVerified($0) } ?? false // A seal is bound to the name it was earned under. Without that, // a key that earned one vouch as "ravi" can rename itself to - // "medic" and keep rendering the seal beside the new name. A local - // petname already outranks the claimed nickname for display, so - // there is nothing to spoof and the seal stands. - let hasPetname = !(peer.localPetname ?? "").isEmpty - let renamedSinceTrust = !hasPetname && (fingerprint.map { - chatViewModel.trustedNicknameMismatch($0, claimedNickname: peer.nickname) - } ?? false) + // "medic" and keep rendering the seal beside the new name. The + // check compares announced names, NOT `peer.nickname`, which is + // already collision-resolved: two connected peers both claiming + // "medic" are rendered "medic#a1b2" and "medic#c3d4", so comparing + // the displayed string would drop the real medic's seal during the + // very attack this defends against. Petnames are handled there too. + let renamedSinceTrust = fingerprint.map { + chatViewModel.trustedNicknameMismatch($0) + } ?? false let verifiedBadge = !peer.isConnected && isVerifiedFingerprint && !renamedSinceTrust // Vouched is subordinate to verified: never show both seals. let vouchedBadge = !isVerifiedFingerprint && !renamedSinceTrust diff --git a/bitchat/Identity/SecureIdentityStateManager.swift b/bitchat/Identity/SecureIdentityStateManager.swift index f27f4b0d..91b82624 100644 --- a/bitchat/Identity/SecureIdentityStateManager.swift +++ b/bitchat/Identity/SecureIdentityStateManager.swift @@ -130,9 +130,9 @@ protocol SecureIdentityStateManagerProtocol { func setVerified(fingerprint: String, verified: Bool) func isVerified(fingerprint: String) -> Bool func getVerifiedFingerprints() -> Set - /// Whether this peer is now claiming a different nickname than the one its + /// Whether this peer now announces a different nickname than the one its /// trust was earned under. - func trustedNicknameMismatch(fingerprint: String, claimedNickname: String) -> Bool + func trustedNicknameMismatch(fingerprint: String) -> Bool // MARK: Vouching (transitive verification) @discardableResult @@ -153,17 +153,6 @@ protocol SecureIdentityStateManagerProtocol { func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool } -extension SecureIdentityStateManagerProtocol { - /// `trustedNicknameMismatch` for a name as *rendered* in a message row, - /// which may carry a `#abcd` disambiguation suffix that an announced - /// nickname never has. Comparing the displayed string verbatim would read - /// every suffixed sender as a mismatch. - func trustedNicknameMismatch(fingerprint: String, displayedSender: String) -> Bool { - trustedNicknameMismatch(fingerprint: fingerprint, - claimedNickname: displayedSender.splitSuffix().0) - } -} - /// Singleton manager for secure identity state persistence and retrieval. /// Provides thread-safe access to identity mappings with encryption at rest. /// All identity data is stored encrypted in the device Keychain for security. @@ -762,15 +751,31 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { cache.trustedNicknames = pinned } - /// True only when a baseline exists AND the peer now claims something else. + /// True only when a baseline exists AND the peer now announces something + /// else. + /// + /// Deliberately compares announced name to announced name rather than to + /// anything on screen. Display strings are decorated: `PeerDisplayNameResolver` + /// appends `#abcd` to CONNECTED peers whose nicknames collide — which is + /// precisely what happens during this attack — so comparing a rendered name + /// would suppress the seal of the peer being impersonated, exactly when it + /// matters most. + /// /// Fails OPEN on a missing baseline on purpose: peers trusted by builds - /// before this existed have none, and dropping their badges on upgrade - /// would train users to ignore the signal. - func trustedNicknameMismatch(fingerprint: String, claimedNickname: String) -> Bool { + /// before this existed have none, and dropping their seals on upgrade would + /// train users to ignore the signal. + func trustedNicknameMismatch(fingerprint: String) -> Bool { queue.sync { - guard let pinned = cache.trustedNicknames?[fingerprint], !pinned.isEmpty, - !claimedNickname.isEmpty else { return false } - return pinned != claimedNickname + guard let pinned = cache.trustedNicknames?[fingerprint], !pinned.isEmpty + else { return false } + let social = cache.socialIdentities[fingerprint] + // A local petname outranks the claimed nickname everywhere it is + // displayed, so there is nothing to spoof and the seal stands. + if let petname = social?.localPetname, !petname.isEmpty { return false } + guard let claimed = social?.claimedNickname, !claimed.isEmpty else { return false } + // NFC, matching `normalizedNickname` everywhere else nicknames are + // compared: a decomposed and a precomposed "café" are one name. + return pinned.normalizedNickname != claimed.normalizedNickname } } diff --git a/bitchat/ViewModels/ChatMessageFormatter.swift b/bitchat/ViewModels/ChatMessageFormatter.swift index 51d67d77..bb661a35 100644 --- a/bitchat/ViewModels/ChatMessageFormatter.swift +++ b/bitchat/ViewModels/ChatMessageFormatter.swift @@ -449,9 +449,9 @@ private extension ChatMessageFormatter { return false } guard viewModel.peerIdentityStore.isVerified(fingerprint) else { return false } - // Bound to the rendered name, for the same reason as the peer list and - // the DM seal: the badge attests to a key but is read as a name. - return !viewModel.trustedNicknameMismatch(fingerprint, displayedSender: message.sender) + // Bound to the name the key announces, for the same reason as the peer + // list and the DM seal: the badge attests to a key but is read as a name. + return !viewModel.trustedNicknameMismatch(fingerprint) } func appendVerifiedSeal( diff --git a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift index e2d0fae8..fbe8338e 100644 --- a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift @@ -76,6 +76,8 @@ protocol ChatPeerIdentityContext: AnyObject { /// falling back to `fallback` when none was stored. Returns the migrated fingerprint. func migrateFingerprintMapping(from oldPeerID: PeerID, to newPeerID: PeerID, fallback: String?) -> String? func isVerifiedFingerprint(_ fingerprint: String) -> Bool + /// Whether the peer renamed itself since its trust was established. + func trustedNicknameMismatch(_ fingerprint: String) -> Bool func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) func cachedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus? func setCachedEncryptionStatus(_ status: EncryptionStatus, for peerID: PeerID) @@ -437,7 +439,9 @@ final class ChatPeerIdentityCoordinator { @MainActor func getEncryptionStatus(for peerID: PeerID) -> EncryptionStatus { if let cachedStatus = context.cachedEncryptionStatus(for: peerID) { - return cachedStatus + // Demote on the cache-hit path too — this is the common one, and + // nothing invalidates the cache on a rename. + return demotedIfRenamed(cachedStatus, for: peerID) } // The status must reflect the LIVE session, never history. The old @@ -462,7 +466,26 @@ final class ChatPeerIdentityCoordinator { } context.setCachedEncryptionStatus(status, for: peerID) - return status + return demotedIfRenamed(status, for: peerID) + } + + /// `.noiseVerified` renders the same filled seal as the verified badge, and + /// for a CONNECTED peer it is the only thing that draws one — so the name + /// binding has to apply here too, or the live impersonation keeps its seal. + /// + /// Applied on the way out rather than stored: the cached status is only + /// invalidated by verification and session changes, never by a rename, so a + /// demotion written into the cache would be both stale and sticky. The + /// session really is Noise-secured; only the claim about *who* is weakened, + /// which is exactly the difference between the two cases. Nothing branches + /// on `.noiseVerified` beyond the glyph — every behavioural site treats it + /// and `.noiseSecured` alike. + @MainActor + private func demotedIfRenamed(_ status: EncryptionStatus, for peerID: PeerID) -> EncryptionStatus { + guard status == .noiseVerified, + let fingerprint = getFingerprint(for: peerID), + context.trustedNicknameMismatch(fingerprint) else { return status } + return .noiseSecured } @MainActor diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 660c8e6c..b2d9f48c 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1973,27 +1973,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage identityManager.isVouched(fingerprint: fingerprint) } - /// Whether this peer now claims a different nickname than the one its - /// trust was established under. Trust badges are suppressed in that case: - /// the seal attests to a key, but it is read next to a name, and a key that - /// renamed itself onto a name the user trusts is the impersonation this - /// warns about. + /// Whether this peer now announces a different nickname than the one its + /// trust was established under. Every seal is suppressed in that case: the + /// seal attests to a key, but it is read as a name, and a key that renamed + /// itself onto a name the user trusts is the impersonation this catches. @MainActor - func trustedNicknameMismatch(_ fingerprint: String, claimedNickname: String) -> Bool { - identityManager.trustedNicknameMismatch( - fingerprint: fingerprint, - claimedNickname: claimedNickname - ) - } - - /// The same check for a name as *rendered* in a message row. See the - /// `displayedSender` overload on `SecureIdentityStateManagerProtocol`. - @MainActor - func trustedNicknameMismatch(_ fingerprint: String, displayedSender: String) -> Bool { - identityManager.trustedNicknameMismatch( - fingerprint: fingerprint, - displayedSender: displayedSender - ) + func trustedNicknameMismatch(_ fingerprint: String) -> Bool { + identityManager.trustedNicknameMismatch(fingerprint: fingerprint) } // MARK: - BitchatDelegate Methods diff --git a/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift b/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift index 0bfcd306..d6e69821 100644 --- a/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift +++ b/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift @@ -144,6 +144,12 @@ private final class MockChatPeerIdentityContext: ChatPeerIdentityContext { verifiedFingerprintSet.contains(fingerprint) } + var renamedSinceTrust: Set = [] + + func trustedNicknameMismatch(_ fingerprint: String) -> Bool { + renamedSinceTrust.contains(fingerprint) + } + func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) { encryptionStatuses[peerID] = status } @@ -331,6 +337,52 @@ struct ChatPeerIdentityCoordinatorContextTests { #expect(context.unreadPrivateMessages.isEmpty) } + @Test @MainActor + func getEncryptionStatus_demotesToSecuredWhenTheKeyRenamedSinceTrust() async { + // `.noiseVerified` renders the same filled seal as the verified badge, + // and for a CONNECTED peer it is the only thing that draws one. A + // verified key that renames itself onto a trusted name must therefore + // lose it here too, or the live impersonation keeps its seal. + let context = MockChatPeerIdentityContext() + let coordinator = ChatPeerIdentityCoordinator(context: context) + let peerID = PeerID(str: "1122334455667788") + + context.noiseSessionStates[peerID] = .established + context.fingerprintsByPeerID[peerID] = "fp" + context.verifiedFingerprintSet = ["fp"] + #expect(coordinator.getEncryptionStatus(for: peerID) == .noiseVerified) + + // The rename lands. Nothing invalidates the encryption cache on a + // nickname change, so the demotion has to survive the cached value. + context.renamedSinceTrust = ["fp"] + #expect(coordinator.getEncryptionStatus(for: peerID) == .noiseSecured, + "still encrypted, no longer a claim about who") + #expect(context.cachedEncryptionStatuses[peerID] == .noiseVerified, + "the demotion is computed on read, never written into the cache") + + // Re-verifying under the new name restores it. + context.renamedSinceTrust = [] + #expect(coordinator.getEncryptionStatus(for: peerID) == .noiseVerified) + } + + @Test @MainActor + func getEncryptionStatus_leavesUnverifiedStatusesAlone() async { + let context = MockChatPeerIdentityContext() + let coordinator = ChatPeerIdentityCoordinator(context: context) + let peerID = PeerID(str: "1122334455667788") + + // A secured-but-unverified session has no seal to lose, and a rename + // must not disturb the lock it does show. + context.noiseSessionStates[peerID] = .established + context.fingerprintsByPeerID[peerID] = "fp" + context.renamedSinceTrust = ["fp"] + #expect(coordinator.getEncryptionStatus(for: peerID) == .noiseSecured) + + coordinator.invalidateEncryptionCache(for: peerID) + context.noiseSessionStates[peerID] = .handshaking + #expect(coordinator.getEncryptionStatus(for: peerID) == .noiseHandshaking) + } + @Test @MainActor func getEncryptionStatus_computesVerifiedStatusAndCachesIt() async { let context = MockChatPeerIdentityContext() diff --git a/bitchatTests/Mocks/MockIdentityManager.swift b/bitchatTests/Mocks/MockIdentityManager.swift index c5fa97df..f0e1b656 100644 --- a/bitchatTests/Mocks/MockIdentityManager.swift +++ b/bitchatTests/Mocks/MockIdentityManager.swift @@ -100,10 +100,12 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol { var trustedNicknames: [String: String] = [:] - func trustedNicknameMismatch(fingerprint: String, claimedNickname: String) -> Bool { + var claimedNicknames: [String: String] = [:] + + func trustedNicknameMismatch(fingerprint: String) -> Bool { guard let pinned = trustedNicknames[fingerprint], !pinned.isEmpty, - !claimedNickname.isEmpty else { return false } - return pinned != claimedNickname + let claimed = claimedNicknames[fingerprint], !claimed.isEmpty else { return false } + return pinned != claimed } func isVerified(fingerprint: String) -> Bool { diff --git a/bitchatTests/Services/SecureIdentityStateManagerNicknameBindingTests.swift b/bitchatTests/Services/SecureIdentityStateManagerNicknameBindingTests.swift index 5a76c6eb..33945919 100644 --- a/bitchatTests/Services/SecureIdentityStateManagerNicknameBindingTests.swift +++ b/bitchatTests/Services/SecureIdentityStateManagerNicknameBindingTests.swift @@ -1,13 +1,14 @@ import Foundation +import BitFoundation import Testing @testable import bitchat -/// Binds a trust badge to the nickname it was earned under. +/// Binds a trust seal to the nickname it was earned under. /// /// `VouchAttestation` signs `voucheeFingerprint | voucheeSigningKey | /// timestampMs` and deliberately says nothing about a name — a name-free -/// attestation is the right wire format. But the badge is *rendered* beside a +/// attestation is the right wire format. But the seal is *rendered* beside a /// self-claimed nickname, so the binding has to live on the receiver, which is /// the only party that knows what name the key was presenting when it decided /// to trust it. These tests pin that rule. @@ -37,18 +38,24 @@ struct SecureIdentityStateManagerNicknameBindingTests { ) } + private func setPetname(_ manager: SecureIdentityStateManager, + _ fingerprint: String, + _ petname: String?) { + guard var identity = manager.getSocialIdentity(for: fingerprint) else { return } + identity.localPetname = petname + manager.updateSocialIdentity(identity) + } + // MARK: - The attack this closes @Test - func vouch_pinsTheNicknameItWasEarnedUnder() { + func vouchPinsTheNicknameItWasEarnedUnder() { let manager = makeManager() announce(manager, vouchee, as: "ravi") manager.setVerified(fingerprint: voucher, verified: true) #expect(manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date())) - #expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "ravi")) - #expect(manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "medic"), - "the baseline is pinned: any other name is a mismatch") + #expect(!manager.trustedNicknameMismatch(fingerprint: vouchee)) } @Test @@ -58,14 +65,11 @@ struct SecureIdentityStateManagerNicknameBindingTests { manager.setVerified(fingerprint: voucher, verified: true) manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date()) - // Same key, new self-chosen name — the badge must not follow it. announce(manager, vouchee, as: "medic") #expect(manager.isVouched(fingerprint: vouchee), "the vouch itself is still valid; only its binding to a name broke") - #expect(manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "medic")) - #expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "ravi"), - "the baseline stayed on the name the vouch was earned under") + #expect(manager.trustedNicknameMismatch(fingerprint: vouchee)) } @Test @@ -76,12 +80,10 @@ struct SecureIdentityStateManagerNicknameBindingTests { manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date()) announce(manager, vouchee, as: "medic") - // A second vouch arriving after the rename must not launder it. manager.setVerified(fingerprint: secondVoucher, verified: true) manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: secondVoucher, timestamp: Date()) - #expect(manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "medic")) - #expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "ravi"), + #expect(manager.trustedNicknameMismatch(fingerprint: vouchee), "the second vouch did not launder the rename") } @@ -95,21 +97,20 @@ struct SecureIdentityStateManagerNicknameBindingTests { let manager = makeManager() manager.setVerified(fingerprint: voucher, verified: true) manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date()) - #expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "ravi"), + #expect(!manager.trustedNicknameMismatch(fingerprint: vouchee), "nothing is bound yet, so nothing can mismatch") announce(manager, vouchee, as: "ravi") - announce(manager, vouchee, as: "medic") + #expect(!manager.trustedNicknameMismatch(fingerprint: vouchee)) - #expect(manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "medic")) - #expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "ravi"), - "the first name seen while the vouch stood is the binding") + announce(manager, vouchee, as: "medic") + #expect(manager.trustedNicknameMismatch(fingerprint: vouchee)) } @Test func theAnnouncePathDoesNotBindBeforeTrustExists() { // Otherwise a peer who renamed BEFORE being vouched would be bound to - // the name we happened to see first, and lose a legitimate badge. + // the name we happened to see first, and lose a legitimate seal. let manager = makeManager() announce(manager, vouchee, as: "rav") announce(manager, vouchee, as: "ravi") @@ -117,9 +118,8 @@ struct SecureIdentityStateManagerNicknameBindingTests { manager.setVerified(fingerprint: voucher, verified: true) manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date()) - #expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "ravi"), + #expect(!manager.trustedNicknameMismatch(fingerprint: vouchee), "bound to the name it was vouched under, not the name seen first") - #expect(manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "rav")) } @Test @@ -134,10 +134,67 @@ struct SecureIdentityStateManagerNicknameBindingTests { announce(manager, vouchee, as: "medic") - #expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "medic"), + #expect(!manager.trustedNicknameMismatch(fingerprint: vouchee), "known gap, pinned here so it is explicit rather than a surprise") } + // MARK: - Not fooled by display decoration + + @Test + func theCheckIsUnaffectedByCollisionSuffixes() { + // `PeerDisplayNameResolver` renders two CONNECTED peers who both claim + // "medic" as "medic#a1b2" and "medic#c3d4" — which is exactly what + // happens during this attack. Comparing a rendered name would drop the + // real medic's seal at the worst possible moment, so the check reads + // announced names only. This pins that the resolver really does + // decorate, and that the binding ignores it. + let real = PeerDisplayNameResolver.resolve( + [(peerID: PeerID(str: "a1b2c3d4"), nickname: "medic", isConnected: true), + (peerID: PeerID(str: "c3d4e5f6"), nickname: "medic", isConnected: true)], + selfNickname: "me") + #expect(real[PeerID(str: "a1b2c3d4")] == "medic#a1b2", "the resolver does decorate") + + let manager = makeManager() + announce(manager, vouchee, as: "medic") + manager.setVerified(fingerprint: vouchee, verified: true) + announce(manager, vouchee, as: "medic") // still "medic" on the wire + + #expect(!manager.trustedNicknameMismatch(fingerprint: vouchee), + "the impersonated peer keeps its seal while a namesake is connected") + } + + @Test + func aLocalPetnameKeepsTheSeal() { + // A petname outranks the claimed nickname everywhere it is displayed, + // so a rename cannot spoof anything and the seal stands. + let manager = makeManager() + announce(manager, vouchee, as: "ravi") + manager.setVerified(fingerprint: vouchee, verified: true) + announce(manager, vouchee, as: "medic") + #expect(manager.trustedNicknameMismatch(fingerprint: vouchee)) + + setPetname(manager, vouchee, "my neighbour") + #expect(!manager.trustedNicknameMismatch(fingerprint: vouchee)) + + setPetname(manager, vouchee, nil) + #expect(manager.trustedNicknameMismatch(fingerprint: vouchee)) + } + + @Test + func namesAreComparedInCanonicalForm() { + // Same rule as `normalizedNickname` everywhere else: a decomposed and a + // precomposed "café" are one name, not a rename. + let manager = makeManager() + announce(manager, vouchee, as: "cafe\u{0301}") // e + combining acute + manager.setVerified(fingerprint: vouchee, verified: true) + + announce(manager, vouchee, as: "caf\u{00E9}") // precomposed é + #expect(!manager.trustedNicknameMismatch(fingerprint: vouchee)) + + announce(manager, vouchee, as: "cafe") + #expect(manager.trustedNicknameMismatch(fingerprint: vouchee)) + } + // MARK: - Rebinding and clearing @Test @@ -147,13 +204,12 @@ struct SecureIdentityStateManagerNicknameBindingTests { manager.setVerified(fingerprint: voucher, verified: true) manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date()) announce(manager, vouchee, as: "medic") + #expect(manager.trustedNicknameMismatch(fingerprint: vouchee)) // The user scanned this key themselves, under the name it shows now. manager.setVerified(fingerprint: vouchee, verified: true) - #expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "medic")) - #expect(manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "ravi"), - "the baseline moved to the name just checked in person") + #expect(!manager.trustedNicknameMismatch(fingerprint: vouchee)) } @Test @@ -162,71 +218,39 @@ struct SecureIdentityStateManagerNicknameBindingTests { announce(manager, vouchee, as: "ravi") manager.setVerified(fingerprint: vouchee, verified: true) - #expect(manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "medic")) + announce(manager, vouchee, as: "medic") + #expect(manager.trustedNicknameMismatch(fingerprint: vouchee)) + manager.setVerified(fingerprint: vouchee, verified: false) - #expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "medic"), + #expect(!manager.trustedNicknameMismatch(fingerprint: vouchee), "with no trust left there is no baseline, so nothing to mismatch") // With a vouch still standing, the baseline has to survive: it is what - // that vouch's badge is bound to. + // that vouch's seal is bound to. + announce(manager, vouchee, as: "ravi") manager.setVerified(fingerprint: voucher, verified: true) manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date()) manager.setVerified(fingerprint: vouchee, verified: true) manager.setVerified(fingerprint: vouchee, verified: false) - #expect(manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "medic"), - "the vouch's badge still needs the name it was bound to") - } - - // MARK: - Names as rendered in a message row - - @Test - func aDisambiguationSuffixIsNotMistakenForARename() { - // Message senders render as "ravi#a1b2" when nicknames collide; the - // announced nickname never carries the suffix. - let manager = makeManager() - announce(manager, vouchee, as: "ravi") - manager.setVerified(fingerprint: vouchee, verified: true) - - #expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, displayedSender: "ravi#a1b2")) - #expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, displayedSender: "@ravi#a1b2")) - #expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, displayedSender: "ravi")) - } - - @Test - func aRenameIsStillCaughtThroughTheSuffix() { - let manager = makeManager() - announce(manager, vouchee, as: "ravi") - manager.setVerified(fingerprint: vouchee, verified: true) - - #expect(manager.trustedNicknameMismatch(fingerprint: vouchee, displayedSender: "medic#a1b2")) - #expect(manager.trustedNicknameMismatch(fingerprint: vouchee, displayedSender: "medic")) - } - - @Test - func aNameEndingInSomethingSuffixLikeIsNotTruncated() { - // "#abcd" only counts as a suffix when those four characters are hex; - // a nickname that merely contains '#' must still compare whole. - let manager = makeManager() - announce(manager, vouchee, as: "ravi#zzzz") - manager.setVerified(fingerprint: vouchee, verified: true) - - #expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, displayedSender: "ravi#zzzz")) - #expect(manager.trustedNicknameMismatch(fingerprint: vouchee, displayedSender: "ravi")) + announce(manager, vouchee, as: "medic") + #expect(manager.trustedNicknameMismatch(fingerprint: vouchee), + "the vouch's seal still needs the name it was bound to") } // MARK: - Failing open @Test - func aMissingBaselineNeverSuppressesABadge() { + func aMissingBaselineNeverSuppressesASeal() { // Verified before the peer ever announced a name — there is nothing to // bind to, and pinning "" would read as a mismatch against every later // announce. Peers trusted by builds before this shipped land here too, - // so an upgrade must not silently drop their badges. + // so an upgrade must not silently drop their seals. let manager = makeManager() manager.setVerified(fingerprint: vouchee, verified: true) + #expect(!manager.trustedNicknameMismatch(fingerprint: vouchee)) - #expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "medic")) - #expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "ravi")) + announce(manager, vouchee, as: "medic") + #expect(!manager.trustedNicknameMismatch(fingerprint: vouchee)) } @Test @@ -234,8 +258,9 @@ struct SecureIdentityStateManagerNicknameBindingTests { let manager = makeManager() announce(manager, vouchee, as: "ravi") manager.setVerified(fingerprint: vouchee, verified: true) + announce(manager, vouchee, as: "") - #expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "")) + #expect(!manager.trustedNicknameMismatch(fingerprint: vouchee)) } // MARK: - Persistence compatibility diff --git a/bitchatTests/Services/UnifiedPeerServiceTests.swift b/bitchatTests/Services/UnifiedPeerServiceTests.swift index 7dfae43b..0176634a 100644 --- a/bitchatTests/Services/UnifiedPeerServiceTests.swift +++ b/bitchatTests/Services/UnifiedPeerServiceTests.swift @@ -291,7 +291,7 @@ private final class TestIdentityManager: SecureIdentityStateManagerProtocol { // No name binding is exercised here: these tests drive peer-list assembly, // not trust display. - func trustedNicknameMismatch(fingerprint: String, claimedNickname: String) -> Bool { false } + func trustedNicknameMismatch(fingerprint: String) -> Bool { false } // MARK: Vouching (unused by these tests)