Bind a trust seal to the nickname it was earned under

A vouched or verified key can rename itself onto a nickname the user trusts
and keep rendering the seal beside the new name.

`VouchAttestation.signableBytes` covers `voucheeFingerprint |
voucheeSigningKey | timestampMs` and says nothing about a name — which is
right; the attestation should stay name-free. But the seal is *rendered* beside
a self-claimed nickname: `PeerListModel` derives `verifiedBadge`/`vouchedBadge`
from the fingerprint and then shows `peer.displayName`, which falls back to the
peer-claimed `nickname` whenever `localPetname` is unset. So the seal is read
next to a name that nothing binds it to. Earn one genuine vouch as "ravi",
announce "medic", and every device that trusts the real medic now shows a
second trusted-looking medic.

This is the mirror of #1661's case. That one is same nickname, new key; this is
same key, new nickname.

The binding has to live on the receiver, because the receiver is the only party
that knows which name the key was presenting when it decided to trust it. So
`IdentityCache` gains `trustedNicknames`, pinned when trust is established, and
all three places that render a seal — the peer list, the public-timeline seal
and the DM seal — suppress it while the peer presents a different name.

Where it is pinned, and why:

  - On the FIRST vouch only, never moved by a later one. Otherwise an attacker
    renames and the next voucher silently re-anchors the seal to the new name.
  - Re-verifying DOES move it: the user just checked this key in person, under
    whatever name it shows now.
  - On the first announce seen while trust already stands. A vouch normally
    arrives for a peer we have not seen announce — it comes over Noise from
    someone else, and the vouchee may be hops away — so there is no name to
    bind to at record time. The announce path deliberately does NOT pin before
    trust exists: binding to whatever name we happened to see first would
    suppress the seal of a peer who renamed *before* being vouched.
  - Never on an empty nickname, and a missing baseline never suppresses. Peers
    trusted by earlier builds have none, and dropping their seals on upgrade
    would teach users to ignore the signal.

Each seal site compares against the name it actually renders, not against the
stored `claimedNickname`. That matters: offline favourite rows take their name
from the persisted favourite relationship, which can lag the last announce, and
a message row shows the sender as of that message. Comparing the rendered
string keeps the seal truthful about the name the user is looking at. Message
senders can carry a `#abcd` disambiguation suffix that an announced nickname
never has, so a `displayedSender` overload strips it with the existing
`splitSuffix()`. That overload is a protocol extension rather than a new
requirement, so it needs no mock changes and its logic sits in a layer the
tests can reach.

A local petname already outranks the claimed nickname for display, so there is
nothing to spoof there and the seal stands.

The known limit, pinned by a test so it is explicit: if we never saw the key
under its real name, its first announce IS the baseline, so an impersonating
first announce is not caught. Closing that needs the nickname signed into the
attestation, which is a wire change and deliberately not attempted here.

`FingerprintView`'s vouched panel is left alone: that sheet is an explicit
inspection surface keyed by fingerprint, where showing the trust state is the
point.

No wire or protocol change, and no new user-facing strings. Suppressing the
seal is the security fix; explaining it in the UI wants the notice plumbing
#1661 is adding, so it is left for a follow-up. `trustedNicknames` decodes with
`decodeIfPresent` like the other post-v1 fields, so caches written by older
builds still load.

Tests: SecureIdentityStateManagerNicknameBindingTests (15) covers the rename
attack, the second-voucher laundering attempt, the vouch-before-first-announce
path and its residual gap, the no-pin-before-trust rule, suffix handling in
both directions, rebinding on re-verification, clearing on unverify only when
no vouch remains, both fail-open cases, and persistence compatibility each way.
`TestIdentityManager` in UnifiedPeerServiceTests also conforms to the protocol
and is updated.
This commit is contained in:
Shubham Bhandari 2026-09-08 17:31:16 +08:00
parent 9b84b36122
commit 37e02c1851
9 changed files with 409 additions and 4 deletions

View File

@ -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? {

View File

@ -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(

View File

@ -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<String>.self, forKey: .privateMediaCapableFingerprints)
authenticatedSigningKeysByFingerprint = try container.decodeIfPresent([String: Data].self, forKey: .authenticatedSigningKeysByFingerprint)
cryptographicIdentities = try container.decodeIfPresent([String: CryptographicIdentity].self, forKey: .cryptographicIdentities) ?? [:]

View File

@ -130,6 +130,9 @@ protocol SecureIdentityStateManagerProtocol {
func setVerified(fingerprint: String, verified: Bool)
func isVerified(fingerprint: String) -> Bool
func getVerifiedFingerprints() -> Set<String>
/// 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<String> {
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
}

View File

@ -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(

View File

@ -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

View File

@ -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
}

View File

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

View File

@ -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