diff --git a/bitchat/App/ConversationUIModel.swift b/bitchat/App/ConversationUIModel.swift index 4883a04c..d1cc595f 100644 --- a/bitchat/App/ConversationUIModel.swift +++ b/bitchat/App/ConversationUIModel.swift @@ -164,7 +164,11 @@ final class ConversationUIModel: ObservableObject { !isSentByCurrentUser(message), let peerID = message.senderPeerID else { return false } guard let fingerprint = chatViewModel.getFingerprint(for: peerID) else { return false } - return chatViewModel.peerIdentityStore.isVerified(fingerprint) + 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) } func senderDisplayName(for peerID: PeerID, fallbackMessages: [BitchatMessage]) -> String? { diff --git a/bitchat/App/PeerListModel.swift b/bitchat/App/PeerListModel.swift index 4f1fc633..779bff26 100644 --- a/bitchat/App/PeerListModel.swift +++ b/bitchat/App/PeerListModel.swift @@ -232,9 +232,18 @@ final class PeerListModel: ObservableObject { let isMe = peer.peerID == myPeerID let fingerprint = isMe ? nil : chatViewModel.getFingerprint(for: peer.peerID) let isVerifiedFingerprint = fingerprint.map { peerIdentityStore.isVerified($0) } ?? false - let verifiedBadge = !peer.isConnected && isVerifiedFingerprint + // 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) + let verifiedBadge = !peer.isConnected && isVerifiedFingerprint && !renamedSinceTrust // Vouched is subordinate to verified: never show both seals. - let vouchedBadge = !isVerifiedFingerprint + let vouchedBadge = !isVerifiedFingerprint && !renamedSinceTrust && (fingerprint.map { chatViewModel.isVouchedFingerprint($0) } ?? false) return MeshPeerRow( diff --git a/bitchat/Identity/IdentityModels.swift b/bitchat/Identity/IdentityModels.swift index 8a72fcba..f82169f6 100644 --- a/bitchat/Identity/IdentityModels.swift +++ b/bitchat/Identity/IdentityModels.swift @@ -202,6 +202,15 @@ struct IdentityCache: Codable { // entries verified before this field exists sort as oldest) var verifiedAt: [String: Date]? = nil + /// The self-claimed nickname each fingerprint was presenting at the moment + /// trust in it was established here — set on verification and on a first + /// accepted vouch. A trust badge means "this key was the Medic I checked", + /// so it has to be pinned to the name it was earned under; without this + /// baseline a vouched key can rename itself onto a name the user trusts and + /// keep rendering the seal beside it. Absent for peers trusted by older + /// builds, which is why a missing entry never suppresses a badge. + var trustedNicknames: [String: String]? = nil + // Stable Noise fingerprints that proved encrypted private-media support // inside an authenticated Noise session. Optional for decoding caches // written before this migration. Entries are monotonic until a panic wipe @@ -240,6 +249,7 @@ struct IdentityCache: Codable { vouchesByVouchee = try container.decodeIfPresent([String: [VouchRecord]].self, forKey: .vouchesByVouchee) vouchBatchSentAt = try container.decodeIfPresent([String: Date].self, forKey: .vouchBatchSentAt) verifiedAt = try container.decodeIfPresent([String: Date].self, forKey: .verifiedAt) + trustedNicknames = try container.decodeIfPresent([String: String].self, forKey: .trustedNicknames) privateMediaCapableFingerprints = try container.decodeIfPresent(Set.self, forKey: .privateMediaCapableFingerprints) authenticatedSigningKeysByFingerprint = try container.decodeIfPresent([String: Data].self, forKey: .authenticatedSigningKeysByFingerprint) cryptographicIdentities = try container.decodeIfPresent([String: CryptographicIdentity].self, forKey: .cryptographicIdentities) ?? [:] diff --git a/bitchat/Identity/SecureIdentityStateManager.swift b/bitchat/Identity/SecureIdentityStateManager.swift index 4204e940..f27f4b0d 100644 --- a/bitchat/Identity/SecureIdentityStateManager.swift +++ b/bitchat/Identity/SecureIdentityStateManager.swift @@ -130,6 +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 + /// trust was earned under. + func trustedNicknameMismatch(fingerprint: String, claimedNickname: String) -> Bool // MARK: Vouching (transitive verification) @discardableResult @@ -150,6 +153,17 @@ 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. @@ -428,6 +442,15 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { } else if self.cache.socialIdentities[fingerprint] == nil { self.cache.socialIdentities[fingerprint] = identity } + // A vouch usually arrives for a peer we have not seen announce + // — it comes over Noise from someone else, and the vouchee may + // be several hops away. There was no name to bind to then, so + // bind on the first announce we see while the trust stands. + self.pinTrustedNicknameLocked( + fingerprint: fingerprint, + overwrite: false, + requireExistingTrust: true + ) } self.saveIdentityCache() @@ -678,9 +701,15 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { var verifiedAt = self.cache.verifiedAt ?? [:] verifiedAt[fingerprint] = Date() self.cache.verifiedAt = verifiedAt + // Re-verifying overwrites the baseline: the user just checked + // this key again, under whatever name it presents now. + self.pinTrustedNicknameLocked(fingerprint: fingerprint, overwrite: true) } else { self.cache.verifiedFingerprints.remove(fingerprint) self.cache.verifiedAt?.removeValue(forKey: fingerprint) + if self.cache.vouchesByVouchee?[fingerprint]?.isEmpty ?? true { + self.cache.trustedNicknames?.removeValue(forKey: fingerprint) + } } // Update trust level if social identity exists @@ -698,6 +727,52 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { return cache.verifiedFingerprints.contains(fingerprint) } } + + // MARK: - Nickname binding + // + // A vouch signs `voucheeFingerprint | voucheeSigningKey | timestampMs` and + // deliberately says nothing about a name — the attestation is right to stay + // name-free. But the badge is *rendered* next to a self-claimed nickname, + // so the binding has to exist somewhere, and the receiver is the only party + // that can hold it: it is the one that decided to trust this key while it + // was presenting a particular name. + + /// Requires `queue`. Records the peer's currently claimed nickname as the + /// name its trust is bound to. Empty nicknames are not pinned — a key + /// verified before its first announce has no name to bind to, and pinning + /// "" would then read as a mismatch against every later announce. + /// + /// `requireExistingTrust` is for the announce path, which runs for every + /// peer: pinning a name before there is any trust would bind the badge to + /// whatever name we happened to see first, so a peer who renamed *before* + /// being vouched would have its legitimate badge suppressed. + private func pinTrustedNicknameLocked(fingerprint: String, + overwrite: Bool, + requireExistingTrust: Bool = false) { + if requireExistingTrust { + let trusted = cache.verifiedFingerprints.contains(fingerprint) + || !(cache.vouchesByVouchee?[fingerprint] ?? []).isEmpty + guard trusted else { return } + } + guard let claimed = cache.socialIdentities[fingerprint]?.claimedNickname, + !claimed.isEmpty else { return } + var pinned = cache.trustedNicknames ?? [:] + if !overwrite, pinned[fingerprint] != nil { return } + pinned[fingerprint] = claimed + cache.trustedNicknames = pinned + } + + /// True only when a baseline exists AND the peer now claims something else. + /// 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 { + queue.sync { + guard let pinned = cache.trustedNicknames?[fingerprint], !pinned.isEmpty, + !claimedNickname.isEmpty else { return false } + return pinned != claimedNickname + } + } func getVerifiedFingerprints() -> Set { queue.sync { @@ -760,6 +835,10 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { var vouches = self.cache.vouchesByVouchee ?? [:] vouches[voucheeFingerprint] = capped self.cache.vouchesByVouchee = vouches + // Pin on the FIRST vouch only. A later vouch for the same key must + // not move the baseline, or an attacker could rename and then have + // a second voucher silently re-anchor the badge to the new name. + self.pinTrustedNicknameLocked(fingerprint: voucheeFingerprint, overwrite: false) self.saveIdentityCache() return true } diff --git a/bitchat/ViewModels/ChatMessageFormatter.swift b/bitchat/ViewModels/ChatMessageFormatter.swift index 1a00315b..51d67d77 100644 --- a/bitchat/ViewModels/ChatMessageFormatter.swift +++ b/bitchat/ViewModels/ChatMessageFormatter.swift @@ -448,7 +448,10 @@ private extension ChatMessageFormatter { let fingerprint = viewModel.getFingerprint(for: peerID) else { return false } - return viewModel.peerIdentityStore.isVerified(fingerprint) + 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) } func appendVerifiedSeal( diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 63fba0f8..660c8e6c 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1973,6 +1973,29 @@ 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. + @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 + ) + } + // MARK: - BitchatDelegate Methods // MARK: - Command Handling diff --git a/bitchatTests/Mocks/MockIdentityManager.swift b/bitchatTests/Mocks/MockIdentityManager.swift index 930d57ff..c5fa97df 100644 --- a/bitchatTests/Mocks/MockIdentityManager.swift +++ b/bitchatTests/Mocks/MockIdentityManager.swift @@ -98,6 +98,14 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol { func setVerified(fingerprint: String, verified: Bool) {} + var trustedNicknames: [String: String] = [:] + + func trustedNicknameMismatch(fingerprint: String, claimedNickname: String) -> Bool { + guard let pinned = trustedNicknames[fingerprint], !pinned.isEmpty, + !claimedNickname.isEmpty else { return false } + return pinned != claimedNickname + } + func isVerified(fingerprint: String) -> Bool { true } diff --git a/bitchatTests/Services/SecureIdentityStateManagerNicknameBindingTests.swift b/bitchatTests/Services/SecureIdentityStateManagerNicknameBindingTests.swift new file mode 100644 index 00000000..5a76c6eb --- /dev/null +++ b/bitchatTests/Services/SecureIdentityStateManagerNicknameBindingTests.swift @@ -0,0 +1,265 @@ +import Foundation +import Testing + +@testable import bitchat + +/// Binds a trust badge 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 +/// 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. +/// +/// `@MainActor` for the same reason as `SecureIdentityStateManagerVouchTests`: +/// the manager's blocking `queue.sync` reads must stay off the Swift +/// Concurrency cooperative pool, or CI's few-core runners deadlock. +@MainActor +struct SecureIdentityStateManagerNicknameBindingTests { + private let voucher = String(repeating: "0a", count: 32) + private let secondVoucher = String(repeating: "0c", count: 32) + private let vouchee = String(repeating: "0b", count: 32) + + private func makeManager() -> SecureIdentityStateManager { + SecureIdentityStateManager(MockKeychain()) + } + + /// Mirrors the announce path: the peer tells us what it calls itself. + private func announce(_ manager: SecureIdentityStateManager, + _ fingerprint: String, + as nickname: String) { + manager.upsertCryptographicIdentity( + fingerprint: fingerprint, + noisePublicKey: Data(repeating: 0x01, count: 32), + signingPublicKey: nil, + claimedNickname: nickname + ) + } + + // MARK: - The attack this closes + + @Test + func vouch_pinsTheNicknameItWasEarnedUnder() { + 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") + } + + @Test + func renamingOntoATrustedNameAfterEarningAVouchIsAMismatch() { + let manager = makeManager() + announce(manager, vouchee, as: "ravi") + 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") + } + + @Test + func aLaterVoucherCannotReanchorTheBaselineToTheNewName() { + let manager = makeManager() + announce(manager, vouchee, as: "ravi") + manager.setVerified(fingerprint: voucher, verified: true) + 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"), + "the second vouch did not launder the rename") + } + + // MARK: - Vouches for peers we have not seen yet + + @Test + func aVouchForAnUnseenPeerBindsOnTheirFirstAnnounce() { + // The usual case: the vouch arrives over Noise from someone else and + // the vouchee is not in our announce set yet, so there is no name to + // bind to at record time. + let manager = makeManager() + manager.setVerified(fingerprint: voucher, verified: true) + manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date()) + #expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "ravi"), + "nothing is bound yet, so nothing can mismatch") + + announce(manager, vouchee, as: "ravi") + announce(manager, vouchee, as: "medic") + + #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") + } + + @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. + let manager = makeManager() + announce(manager, vouchee, as: "rav") + announce(manager, vouchee, as: "ravi") + + manager.setVerified(fingerprint: voucher, verified: true) + manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date()) + + #expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "ravi"), + "bound to the name it was vouched under, not the name seen first") + #expect(manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "rav")) + } + + @Test + func aFirstAnnounceThatIsAlreadyTheImpersonatingNameIsNotCaught() { + // The documented limit of receiver-side binding: if we never saw this + // key under its real name, the impersonating name IS the baseline. + // Only signing the nickname into the attestation closes this, which is + // a wire change and deliberately not attempted here. + let manager = makeManager() + manager.setVerified(fingerprint: voucher, verified: true) + manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date()) + + announce(manager, vouchee, as: "medic") + + #expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "medic"), + "known gap, pinned here so it is explicit rather than a surprise") + } + + // MARK: - Rebinding and clearing + + @Test + func verifyingInPersonRebindsToTheCurrentName() { + let manager = makeManager() + announce(manager, vouchee, as: "ravi") + manager.setVerified(fingerprint: voucher, verified: true) + manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date()) + announce(manager, vouchee, as: "medic") + + // 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") + } + + @Test + func unverifyingClearsTheBaselineOnlyWhenNoVouchRemains() { + let manager = makeManager() + announce(manager, vouchee, as: "ravi") + + manager.setVerified(fingerprint: vouchee, verified: true) + #expect(manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "medic")) + manager.setVerified(fingerprint: vouchee, verified: false) + #expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "medic"), + "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. + 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")) + } + + // MARK: - Failing open + + @Test + func aMissingBaselineNeverSuppressesABadge() { + // 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. + let manager = makeManager() + manager.setVerified(fingerprint: vouchee, verified: true) + + #expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "medic")) + #expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "ravi")) + } + + @Test + func anEmptyClaimedNicknameIsNotAMismatch() { + let manager = makeManager() + announce(manager, vouchee, as: "ravi") + manager.setVerified(fingerprint: vouchee, verified: true) + + #expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "")) + } + + // MARK: - Persistence compatibility + + @Test + func aCacheWrittenBeforeThisFieldExistedStillLoads() throws { + let legacyJSON = Data(""" + {"socialIdentities":{},"nicknameIndex":{},"verifiedFingerprints":["\(vouchee)"],\ + "lastInteractions":{},"blockedNostrPubkeys":[],"cryptographicIdentities":{},"version":1} + """.utf8) + + let decoded = try JSONDecoder().decode(IdentityCache.self, from: legacyJSON) + + #expect(decoded.trustedNicknames == nil) + #expect(decoded.verifiedFingerprints.contains(vouchee)) + } + + @Test + func theBaselineSurvivesAnEncodeDecodeRoundTrip() throws { + var cache = IdentityCache() + cache.trustedNicknames = [vouchee: "ravi"] + + let decoded = try JSONDecoder().decode(IdentityCache.self, from: JSONEncoder().encode(cache)) + + #expect(decoded.trustedNicknames?[vouchee] == "ravi") + } +} diff --git a/bitchatTests/Services/UnifiedPeerServiceTests.swift b/bitchatTests/Services/UnifiedPeerServiceTests.swift index 4bc6f775..7dfae43b 100644 --- a/bitchatTests/Services/UnifiedPeerServiceTests.swift +++ b/bitchatTests/Services/UnifiedPeerServiceTests.swift @@ -289,6 +289,10 @@ private final class TestIdentityManager: SecureIdentityStateManagerProtocol { verified } + // No name binding is exercised here: these tests drive peer-list assembly, + // not trust display. + func trustedNicknameMismatch(fingerprint: String, claimedNickname: String) -> Bool { false } + // MARK: Vouching (unused by these tests) @discardableResult