Merge e2e929b60f5bded77640e7755498133093ee91d9 into 9b84b361225facd8e623f25d76f889d3dc54a879

This commit is contained in:
shubhambhandari126 2026-09-15 10:43:38 +00:00 committed by GitHub
commit 2e372deae6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1077 additions and 18 deletions

View File

@ -164,7 +164,13 @@ 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)
// The row renders `message.sender`, frozen at receipt, so the question
// is whether THAT name is the one this key was verified under — not
// whether its current name is. Checking the current name would let a
// rename away, a post, and a rename back put a seal beside a name the
// key was never trusted under. One call, one lock.
return chatViewModel.sealAppliesToRow(fingerprint, renderedSender: message.sender,
senderPeerID: peerID)
}
func senderDisplayName(for peerID: PeerID, fallbackMessages: [BitchatMessage]) -> String? {

View File

@ -232,9 +232,20 @@ 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. 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
let vouchedBadge = !isVerifiedFingerprint && !renamedSinceTrust
&& (fingerprint.map { chatViewModel.isVouchedFingerprint($0) } ?? false)
return MeshPeerRow(

View File

@ -16,6 +16,15 @@ struct FingerprintPresentationState: Equatable {
let voucherCount: Int
/// Display names of the (verified) vouchers, where known.
let voucherNames: [String]
/// The nickname this key was announcing when it was verified or first
/// vouched, if anything was bound. Non-nil and different from the name on
/// screen is what `nameChangedSinceVerification` reports.
let verifiedAsNickname: String?
/// The key is trusted, but it now announces a different name than when
/// that trust was established. The sheet is the one place this should be
/// explained rather than collapsed into a glyph, so it is surfaced here
/// instead of silently suppressing the badge.
let nameChangedSinceVerification: Bool
/// Vouched for by ≥1 peer the user verified (and not explicitly verified).
var isVouched: Bool { voucherCount > 0 }
@ -136,6 +145,13 @@ final class VerificationModel: ObservableObject {
let isVerified = theirFingerprint.map { peerIdentityStore.isVerified($0) } ?? false
let localPetname = theirFingerprint
.flatMap { chatViewModel.identityManager.getSocialIdentity(for: $0)?.localPetname }
// A vouched seal is bound to the name it was earned under, exactly like
// the verified one. Missing baseline means nothing was bound, so this
// fails open like every other site.
let nameBound = theirFingerprint
.map { !chatViewModel.identityManager.trustedNicknameMismatch(fingerprint: $0) } ?? true
let verifiedAsNickname = theirFingerprint
.flatMap { chatViewModel.identityManager.trustedNickname(fingerprint: $0) }
// Vouch state is recomputed on read: only vouchers still in the
// verified set count, so removing a verification silently retires the
@ -146,11 +162,30 @@ final class VerificationModel: ObservableObject {
} else {
vouchers = []
}
// Each voucher is named by the name YOU VERIFIED IT UNDER, not by
// whatever it announces now.
//
// This list is a trust attribution — "people you verified vouch for
// this peer" — so it has the same problem as a seal, one level out. Use
// the live name and a voucher that renamed is attributed under its new
// one: Eve gets verified as "ravi", vouches for Mallory, renames to
// "medic", and Mallory's sheet reads "vouched by medic". The vouch is
// real and the key is trusted; the NAME beside it was never verified,
// which is the whole subject of this change.
//
// Order: a petname first, because it is your own label and nothing on
// the network can influence it; then the pinned baseline; then the
// claimed nickname, for vouchers recorded before baselines existed —
// the same fail-open as everywhere else here.
let voucherNames = vouchers.compactMap { record -> String? in
guard let social = chatViewModel.identityManager.getSocialIdentity(for: record.voucherFingerprint) else {
return nil
}
if let petname = social.localPetname, !petname.isEmpty { return petname }
if let pinned = chatViewModel.identityManager.trustedNickname(
fingerprint: record.voucherFingerprint), !pinned.isEmpty {
return pinned
}
return social.claimedNickname.isEmpty ? nil : social.claimedNickname
}
@ -161,8 +196,12 @@ final class VerificationModel: ObservableObject {
myFingerprint: chatViewModel.getMyFingerprint(),
isVerified: isVerified,
localPetname: localPetname,
voucherCount: vouchers.count,
voucherNames: voucherNames
// Suppressed together: a voucher list beside a name nobody
// vouched for is the same claim as the seal itself.
voucherCount: nameBound ? vouchers.count : 0,
voucherNames: nameBound ? voucherNames : [],
verifiedAsNickname: verifiedAsNickname,
nameChangedSinceVerification: !nameBound
)
}

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,16 @@ protocol SecureIdentityStateManagerProtocol {
func setVerified(fingerprint: String, verified: Bool)
func isVerified(fingerprint: String) -> Bool
func getVerifiedFingerprints() -> Set<String>
/// The nickname `fingerprint` was announcing when trust in it was
/// established, or nil if nothing was bound. Shown to the user, so a sheet
/// can say *which* name was verified rather than only that one changed.
func trustedNickname(fingerprint: String) -> String?
/// Whether this peer now announces a different nickname than the one its
/// trust was earned under.
func trustedNicknameMismatch(fingerprint: String) -> Bool
/// Verified AND `renderedSender` is the name it was verified under — what
/// a seal beside a name frozen on a message row is asserting.
func sealAppliesToRow(fingerprint: String, renderedSender: String, senderPeerID: PeerID?) -> Bool
// MARK: Vouching (transitive verification)
@discardableResult
@ -150,6 +160,20 @@ protocol SecureIdentityStateManagerProtocol {
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool
}
extension SecureIdentityStateManagerProtocol {
// A default implementation so a conformance only has to provide the two
// primitives. `SecureIdentityStateManager` overrides it to answer in a
// single lock acquisition — this runs per row, per render, and the format
// cache does not shield the call in ChatMessageFormatter because the
// answer is part of its cache key.
func sealAppliesToRow(fingerprint: String, renderedSender: String, senderPeerID: PeerID?) -> Bool {
guard isVerified(fingerprint: fingerprint) else { return false }
guard let pinned = trustedNickname(fingerprint: fingerprint), !pinned.isEmpty
else { return true }
return pinned.matchesRenderedName(renderedSender, decoratedFor: senderPeerID)
}
}
/// 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 +452,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 +711,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 +737,94 @@ 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 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 seals on upgrade would
/// train users to ignore the signal.
func trustedNicknameMismatch(fingerprint: String) -> Bool {
queue.sync { liveNameMismatchLocked(fingerprint) }
}
func trustedNickname(fingerprint: String) -> String? {
queue.sync { cache.trustedNicknames?[fingerprint] }
}
/// A message row shows the sender name frozen at receipt, so asking about
/// the peer's CURRENT name is the wrong question: rename away, post, rename
/// back, and the live name matches the baseline again while the archived row
/// still reads the name it was posted under. Each row is therefore checked
/// against its own name, which also makes every row self-consistent — a
/// historical "ravi ✓" stays sealed even while that key is currently
/// renamed, because that row really was ravi.
func sealAppliesToRow(fingerprint: String, renderedSender: String, senderPeerID: PeerID?) -> Bool {
queue.sync {
guard cache.verifiedFingerprints.contains(fingerprint) else { return false }
guard let pinned = cache.trustedNicknames?[fingerprint], !pinned.isEmpty
else { return true } // nothing bound: fail open
// No petname escape here, unlike the live check: the formatter
// renders `message.sender`, never a petname, so a petname does not
// stand between the reader and a spoofed name on this row.
return pinned.matchesRenderedName(renderedSender, decoratedFor: senderPeerID)
}
}
/// Requires `queue`.
private func liveNameMismatchLocked(_ fingerprint: String) -> Bool {
guard let pinned = cache.trustedNicknames?[fingerprint], !pinned.isEmpty
else { return false }
let social = cache.socialIdentities[fingerprint]
// A local petname outranks the claimed nickname everywhere a LIVE name
// 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 }
// See `nicknameBindingKey`: NFC plus case, so a decomposed and a
// precomposed "café" are one name and recasing is not a rename.
return pinned.nicknameBindingKey != claimed.nicknameBindingKey
}
func getVerifiedFingerprints() -> Set<String> {
queue.sync {
@ -760,6 +887,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

@ -50,9 +50,16 @@ enum BLEPeerSenderDisplayName {
localNickname: String,
peers: [PeerID: BLEPeerInfo]
) -> String {
// Folded, for the same reason as `PeerDisplayNameResolver`: the suffix
// exists to make namesakes distinguishable, so a case variant or a
// fullwidth M has to count as a collision. This is the second place
// collisions are counted — it feeds sender names on file transfers —
// and it had the same exact-match blind spot.
let target = PeerDisplayNameResolver.collisionKey(collisionNickname)
let hasCollision = peers.values.contains {
$0.isConnected && $0.nickname == collisionNickname && $0.peerID != peerID
} || localNickname == collisionNickname
$0.isConnected && PeerDisplayNameResolver.collisionKey($0.nickname) == target
&& $0.peerID != peerID
} || PeerDisplayNameResolver.collisionKey(localNickname) == target
guard hasCollision else { return displayName }
return displayName + "#" + String(peerID.id.prefix(4))

View File

@ -3,23 +3,40 @@ import BitFoundation
/// Resolves a stable display name for peers, adding a short suffix when collisions exist.
struct PeerDisplayNameResolver {
/// The form two nicknames are judged to collide in. Never displayed.
static func collisionKey(_ nickname: String) -> String {
nickname.precomposedStringWithCompatibilityMapping.lowercased()
}
/// Computes display names with a `#xxxx` suffix for connected peers when nickname collisions occur.
/// - Parameters:
/// - peers: Array of tuples (peerID, nickname, isConnected).
/// - selfNickname: The local user's current nickname, included in collision counts to suffix remotes matching it.
/// - Returns: Map of peerID -> displayName.
static func resolve(_ peers: [(peerID: PeerID, nickname: String, isConnected: Bool)], selfNickname: String) -> [PeerID: String] {
// Count collisions among connected peers and include our own nickname
// Count collisions on a FOLDED key, not the displayed name. The point
// of the suffix is to make namesakes distinguishable, so anything that
// renders alike has to count as a collision.
//
// Compatibility mapping plus case folding covers more than it looks:
// fullwidth Medic, mathematical 𝐌edic, script ℳedic and roman-numeral
// Ⅿedic all fold to "medic", and NFKC subsumes the canonical folding
// that Swift's String keys were already giving us for free.
//
// What it does NOT cover is cross-script homoglyphs — Cyrillic М and
// Greek Ο have their own compatibility forms — which needs a UTS #39
// confusables table. Suffixing the displayed name is unchanged; only
// the collision key folds.
var counts: [String: Int] = [:]
for p in peers where p.isConnected {
counts[p.nickname, default: 0] += 1
counts[Self.collisionKey(p.nickname), default: 0] += 1
}
counts[selfNickname, default: 0] += 1
counts[Self.collisionKey(selfNickname), default: 0] += 1
var result: [PeerID: String] = [:]
for p in peers {
var name = p.nickname
if p.isConnected, (counts[p.nickname] ?? 0) > 1 {
if p.isConnected, (counts[Self.collisionKey(p.nickname)] ?? 0) > 1 {
name += "#" + String(p.peerID.id.prefix(4))
}
result[p.peerID] = name

View File

@ -7,6 +7,7 @@
//
import Foundation
import BitFoundation // PeerID, for the row decoration check
extension String {
/// Canonical form for nickname storage and comparison (Unicode NFC).
@ -17,6 +18,76 @@ extension String {
precomposedStringWithCanonicalMapping
}
/// The form two nicknames are compared in to decide whether they are the
/// SAME NAME — used for the verified-name binding.
///
/// NFC plus a locale-independent case fold, twice normalised because case
/// folding can itself emit decomposed sequences (Turkish İ lowercases to
/// i + U+0307). Recasing your own nickname is not a rename, so it must not
/// break the binding.
///
/// Deliberately NFC and not NFKC: a fullwidth `Medic` merely *looks* like
/// `Medic`, so it is a different name and must break the binding. Folding
/// look-alikes is `PeerDisplayNameResolver.collisionKey`'s job, which is
/// asking a different question — whether two peers need telling apart.
var nicknameBindingKey: String {
normalizedNickname.lowercased().normalizedNickname
}
/// Strips ONLY a trailing `#abcd` collision suffix, leaving everything
/// else alone.
///
/// Deliberately not `splitSuffix()`: that also removes every `@` in the
/// string, which is right for parsing a mention but wrong for comparing a
/// nickname — nothing in `validateNickname` forbids `@`, so `ravi@hq`
/// would compare unequal to itself.
var withoutCollisionSuffix: String {
guard count >= 5 else { return self }
let tail = suffix(5)
// ASCII hex only, matching how `splitSuffix()` recognises the suffix
// this device generates. `Character.isHexDigit` would also accept
// fullwidth digits, so a nickname literally ending in "#ABCD"
// would be truncated and could then match a baseline it is not.
let isAsciiHex: (Character) -> Bool = { c in
("0"..."9").contains(c) || ("a"..."f").contains(c) || ("A"..."F").contains(c)
}
guard tail.first == "#", tail.dropFirst().allSatisfy(isAsciiHex) else { return self }
return String(dropLast(5))
}
/// Does a seal pinned to `self` still apply beside `rendered` — a name as
/// it appears on a message row, which the list may have decorated?
///
/// `decoration` is the peer's own `#abcd`, built from its peerID exactly as
/// `PeerDisplayNameResolver` builds it. That is what makes this answerable
/// at all: a row string on its own cannot distinguish the list decorating
/// `ravi` with `#a1b2` from the peer announcing the name `ravi#a1b2`.
///
/// This used to strip any trailing `#` plus four hex, unconditionally, and
/// was wrong in both directions because a peer announces whatever string it
/// likes and `#` plus four hex is a legal thing to announce:
///
/// pinned `ravi#cafe`, row shows `ravi#cafe` → seal LOST with no rename
/// pinned `ravi`, row shows `ravi#cafe` → seal KEPT after a rename
///
/// The second is the one that matters, and it survives a "try the raw name
/// first" fix — which is why this takes the peerID instead. `#cafe` is
/// indistinguishable from the decoration this app generates itself, so the
/// row reads as "ravi, disambiguated": a better disguise than an unrelated
/// nickname, not a worse one. Only the suffix this peer would actually be
/// given comes off.
///
/// The live check must NOT use this. What a peer announces has no
/// decoration to remove.
func matchesRenderedName(_ rendered: String, decoratedFor peerID: PeerID?) -> Bool {
if nicknameBindingKey == rendered.nicknameBindingKey { return true }
guard let peerID else { return false }
let mine = "#" + String(peerID.id.prefix(4))
guard rendered.count > mine.count, rendered.hasSuffix(mine) else { return false }
let undecorated = String(rendered.dropLast(mine.count))
return nicknameBindingKey == undecorated.nicknameBindingKey
}
/// Split a nickname into base and a '#abcd' suffix if present
func splitSuffix() -> (String, String) {
let name = self.replacingOccurrences(of: "@", with: "")

View File

@ -448,7 +448,11 @@ private extension ChatMessageFormatter {
let fingerprint = viewModel.getFingerprint(for: peerID) else {
return false
}
return viewModel.peerIdentityStore.isVerified(fingerprint)
// Bound to the name ON THIS ROW, which is frozen at receipt — see
// `sealAppliesToRow`. This check feeds the format cache key, so the
// cache does not shield it; a single call keeps it to one lock.
return viewModel.sealAppliesToRow(fingerprint, renderedSender: message.sender,
senderPeerID: peerID)
}
func appendVerifiedSeal(

View File

@ -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,33 @@ 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.
///
/// No BEHAVIOUR branches on the distinction — `VerificationModel:33`,
/// `ContentSheetViews:659` and `:760` all treat `.noiseVerified` and
/// `.noiseSecured` alike. Four things the user perceives do, and all four
/// are meant to move: the icon (`checkmark.seal.fill` → `lock.fill`), the
/// `description` caption, the `accessibilityDescription` — so VoiceOver
/// stops saying "verified" on a demoted DM header — and the green tint on
/// the nickname at `FingerprintView:87`, which is the one site that does
/// NOT treat the two statuses identically.
@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

View File

@ -1973,6 +1973,31 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
identityManager.isVouched(fingerprint: fingerprint)
}
/// 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) -> Bool {
identityManager.trustedNicknameMismatch(fingerprint: fingerprint)
}
/// The name this key was verified under, for a sheet that needs to say
/// which one it was.
@MainActor
func trustedNickname(_ fingerprint: String) -> String? {
identityManager.trustedNickname(fingerprint: fingerprint)
}
/// A seal beside a name frozen on a message row: is THAT name the one this
/// key was verified under? See `sealAppliesToRow`.
@MainActor
func sealAppliesToRow(_ fingerprint: String, renderedSender: String,
senderPeerID: PeerID?) -> Bool {
identityManager.sealAppliesToRow(fingerprint: fingerprint, renderedSender: renderedSender,
senderPeerID: senderPeerID)
}
// MARK: - BitchatDelegate Methods
// MARK: - Command Handling

View File

@ -223,14 +223,33 @@ struct FingerprintView: View {
// Verification status
if fingerprintState.canToggleVerification {
// The seal is withheld when this key now presents a name it
// was not verified under — and the WORD goes with the glyph.
//
// An earlier revision kept "✓ VERIFIED" here on the grounds
// that the key genuinely is verified. Review put the
// objection better than I had: this badge sits a few lines
// under `peerNickname`, so it is not a claim about a bare
// key, it is a claim about THIS NAME — and that claim is
// false. Worse, this sheet is the screen someone opens
// *because* a seal vanished from a row, so the one surface
// they consult to resolve the doubt was the one endorsing
// the rename.
//
// No new string: the existing not-verified copy names the
// current nickname and offers to verify it, which is the
// recovery — re-verifying re-pins the baseline to the name
// on screen. The key's stored verified state is untouched.
let sealApplies = fingerprintState.isVerified
&& !fingerprintState.nameChangedSinceVerification
VStack(spacing: 12) {
Text(fingerprintState.isVerified ? Strings.verifiedBadge : Strings.notVerifiedBadge)
Text(sealApplies ? Strings.verifiedBadge : Strings.notVerifiedBadge)
.bitchatFont(size: 14, weight: .bold)
.foregroundColor(fingerprintState.isVerified ? Color.green : Color.orange)
.foregroundColor(sealApplies ? Color.green : Color.orange)
.frame(maxWidth: .infinity)
Group {
if fingerprintState.isVerified {
if sealApplies {
Text(Strings.verifiedMessage)
} else {
Text(Strings.verifyHint(fingerprintState.peerNickname))
@ -243,7 +262,7 @@ struct FingerprintView: View {
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity)
if !fingerprintState.isVerified {
if !sealApplies {
Button(action: {
verificationModel.verifyFingerprint(for: peerID)
dismiss()

View File

@ -144,6 +144,12 @@ private final class MockChatPeerIdentityContext: ChatPeerIdentityContext {
verifiedFingerprintSet.contains(fingerprint)
}
var renamedSinceTrust: Set<String> = []
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()

View File

@ -98,6 +98,22 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
func setVerified(fingerprint: String, verified: Bool) {}
var trustedNicknames: [String: String] = [:]
var claimedNicknames: [String: String] = [:]
func trustedNickname(fingerprint: String) -> String? { trustedNicknames[fingerprint] }
func trustedNicknameMismatch(fingerprint: String) -> Bool {
guard let pinned = trustedNicknames[fingerprint], !pinned.isEmpty,
let claimed = claimedNicknames[fingerprint], !claimed.isEmpty else { return false }
// Folded like the real one. A mock that compared raw strings would
// call a recase a rename, so a view test could pass here and fail in
// production — and which fold applies is the whole subject of this
// change, so it is the last thing a double should get wrong.
return pinned.nicknameBindingKey != claimed.nicknameBindingKey
}
func isVerified(fingerprint: String) -> Bool {
true
}

View File

@ -0,0 +1,512 @@
import Foundation
import BitFoundation
import Testing
@testable import bitchat
/// 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 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.
///
/// `@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
)
}
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 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))
}
@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())
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))
}
@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")
manager.setVerified(fingerprint: secondVoucher, verified: true)
manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: secondVoucher, timestamp: Date())
#expect(manager.trustedNicknameMismatch(fingerprint: vouchee),
"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),
"nothing is bound yet, so nothing can mismatch")
announce(manager, vouchee, as: "ravi")
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee))
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 seal.
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),
"bound to the name it was vouched under, not the name seen first")
}
@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),
"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: - A seal beside a name frozen on a message row
@Test
func renamingBackDoesNotRestoreTheSealOnARowPostedUnderTheOtherName() {
// The hole in checking only the CURRENT name: a message row renders
// `message.sender` frozen at receipt, so rename away, post, rename
// back, and the live name matches the baseline again while the archived
// row still reads the name it was posted under.
let manager = makeManager()
announce(manager, vouchee, as: "ravi")
manager.setVerified(fingerprint: vouchee, verified: true)
announce(manager, vouchee, as: "medic") // rename
#expect(manager.trustedNicknameMismatch(fingerprint: vouchee))
announce(manager, vouchee, as: "ravi") // ...and back
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee),
"the live name is bound again, which is why the live check alone is not enough")
#expect(!manager.sealAppliesToRow(fingerprint: vouchee, renderedSender: "medic", senderPeerID: nil),
"the row posted as medic must not be sealed")
#expect(manager.sealAppliesToRow(fingerprint: vouchee, renderedSender: "ravi", senderPeerID: nil),
"a row posted as ravi still is — that row really was ravi")
}
@Test
func aHistoricalRowKeepsItsSealWhileTheKeyIsCurrentlyRenamed() {
// The converse, so the rule is not simply "suppress harder": a row
// posted under the verified name stays truthful even once that key has
// moved on, otherwise ordinary renames retroactively unseal history.
let manager = makeManager()
announce(manager, vouchee, as: "ravi")
manager.setVerified(fingerprint: vouchee, verified: true)
announce(manager, vouchee, as: "medic")
#expect(manager.trustedNicknameMismatch(fingerprint: vouchee), "live name is unbound")
#expect(manager.sealAppliesToRow(fingerprint: vouchee, renderedSender: "ravi", senderPeerID: nil),
"but the archived ravi row is still accurate")
#expect(!manager.sealAppliesToRow(fingerprint: vouchee, renderedSender: "medic", senderPeerID: nil))
}
@Test
func aRowSenderCarryingACollisionSuffixIsStillMatched() {
// Message senders render as "ravi#a1b2" when nicknames collide. Only a
// trailing #abcd is stripped — `splitSuffix()` would also remove every
// "@", and nothing forbids one in a nickname.
let manager = makeManager()
announce(manager, vouchee, as: "ravi@hq")
manager.setVerified(fingerprint: vouchee, verified: true)
#expect(manager.sealAppliesToRow(fingerprint: vouchee, renderedSender: "ravi@hq", senderPeerID: nil))
#expect(manager.sealAppliesToRow(fingerprint: vouchee, renderedSender: "ravi@hq#a1b2", senderPeerID: decorator))
#expect(!manager.sealAppliesToRow(fingerprint: vouchee, renderedSender: "ravi", senderPeerID: nil))
#expect(!manager.sealAppliesToRow(fingerprint: vouchee, renderedSender: "medic#a1b2", senderPeerID: decorator))
}
@Test
func aRowIsNotSealedForAnUnverifiedKeyAndFailsOpenWithNoBaseline() {
let manager = makeManager()
announce(manager, vouchee, as: "ravi")
#expect(!manager.sealAppliesToRow(fingerprint: vouchee, renderedSender: "ravi", senderPeerID: nil),
"no verification, no seal")
// Verified before any announce: nothing bound, so rows stay sealed as
// they did before this existed.
let unbound = makeManager()
unbound.setVerified(fingerprint: vouchee, verified: true)
#expect(unbound.sealAppliesToRow(fingerprint: vouchee, renderedSender: "anything", senderPeerID: nil))
}
@Test
func theBaselineIsKeptSoASheetCanNameIt() {
// Suppressing the seal is the security fix; saying WHICH name was
// verified is what lets a reader recognise the peer instead of
// guessing. The baseline therefore survives the rename it detects.
let manager = makeManager()
announce(manager, vouchee, as: "ravi")
manager.setVerified(fingerprint: vouchee, verified: true)
#expect(manager.trustedNickname(fingerprint: vouchee) == "ravi")
announce(manager, vouchee, as: "medic")
#expect(manager.trustedNicknameMismatch(fingerprint: vouchee))
#expect(manager.trustedNickname(fingerprint: vouchee) == "ravi",
"the baseline is what the sheet needs to name")
}
@Test
func anAnnouncedSuffixIsNotADecoration() {
// Found by generalising a review finding on the Android mirror of this
// patch. `sealAppliesToRow` stripped a trailing "#abcd" from the
// rendered sender unconditionally — but a peer announces whatever
// string it likes, and "#" plus four hex is a legal thing to announce,
// which is why `splitSuffix` exists at all. So the one rule was wrong
// in both directions.
//
// The half that matters: a key pinned as "ravi" renames to "ravi#cafe"
// and its rows keep the seal. "#cafe" is indistinguishable from the
// decoration this app generates itself, so the row reads as "ravi,
// disambiguated" — a better disguise than an unrelated name, not worse.
let manager = makeManager()
announce(manager, vouchee, as: "ravi")
manager.setVerified(fingerprint: vouchee, verified: true)
#expect(!manager.sealAppliesToRow(fingerprint: vouchee, renderedSender: "ravi#cafe", senderPeerID: nil),
"a rename onto a hex-looking suffix must not keep the seal")
// ...and the other half: a key honestly verified under a name that
// simply ends that way must not lose its seal for standing still.
let ends = makeManager()
announce(ends, vouchee, as: "ravi#cafe")
ends.setVerified(fingerprint: vouchee, verified: true)
#expect(ends.sealAppliesToRow(fingerprint: vouchee, renderedSender: "ravi#cafe", senderPeerID: nil),
"an announced name that ends in a suffix still matches itself")
#expect(ends.sealAppliesToRow(fingerprint: vouchee, renderedSender: "ravi#cafe#a1b2", senderPeerID: decorator),
"and still matches once the list decorates it")
#expect(!ends.sealAppliesToRow(fingerprint: vouchee, renderedSender: "ravi", senderPeerID: nil),
"while the undecorated base is a different name")
}
@Test
func theLiveCheckNeverUndecorates() {
// The live question has no decoration to remove: `peer.nickname` is
// what the peer claims. Stripping there would let exactly the rename
// above through on the peer list as well as on a row.
let manager = makeManager()
announce(manager, vouchee, as: "ravi")
manager.setVerified(fingerprint: vouchee, verified: true)
announce(manager, vouchee, as: "ravi#cafe")
#expect(manager.trustedNicknameMismatch(fingerprint: vouchee),
"announcing ravi#cafe after being verified as ravi is a rename")
}
/// A peerID whose first four hex are "a1b2", so `#a1b2` is the decoration
/// `PeerDisplayNameResolver` would give this peer and nothing else is.
private var decorator: PeerID { PeerID(str: "a1b2c3d4") }
@Test
func theSheetStopsEndorsingAfterARename() {
// The fingerprint sheet drives its badge, its message and its verify
// button off `nameChangedSinceVerification`, so the word demotes with
// the glyph. Pinned here at the state level, which is what the view
// reads: the view itself needs an app bundle these tests cannot build.
let manager = makeManager()
announce(manager, vouchee, as: "ravi")
manager.setVerified(fingerprint: vouchee, verified: true)
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee),
"nothing to explain while the name holds")
announce(manager, vouchee, as: "medic")
#expect(manager.trustedNicknameMismatch(fingerprint: vouchee),
"the sheet is told the name changed, so its copy can demote")
#expect(manager.trustedNickname(fingerprint: vouchee) == "ravi",
"and the old name survives, so a future revision can name it")
#expect(manager.isVerified(fingerprint: vouchee),
"while the stored verification is untouched — re-verifying is the recovery")
}
@Test
func aVoucherIsNamedByTheNameItWasVerifiedUnder() {
// The voucher list in the fingerprint sheet is a trust attribution, so
// it has the seal's problem one level out: name a voucher by what it
// announces NOW and a renamed voucher is credited under the new name.
// Eve verified as "ravi", vouches for Mallory, renames to "medic", and
// Mallory's sheet reads "vouched by medic".
//
// The baseline outlives the rename precisely so the sheet can say
// "ravi" — the person you actually checked. Pinned at the manager,
// which is where the view reads it from.
let manager = makeManager()
announce(manager, vouchee, as: "ravi")
manager.setVerified(fingerprint: vouchee, verified: true)
announce(manager, vouchee, as: "medic")
#expect(manager.trustedNickname(fingerprint: vouchee) == "ravi",
"a voucher that renamed is still named by what it was verified as")
}
// MARK: - What counts as the same name
@Test
func recasingYourOwnNicknameIsNotARename() {
// Found auditing my own revision: the binding compared NFC only, so
// changing "Ravi" to "ravi" broke it and silently dropped the seal.
let manager = makeManager()
announce(manager, vouchee, as: "Ravi")
manager.setVerified(fingerprint: vouchee, verified: true)
announce(manager, vouchee, as: "ravi")
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee))
#expect(manager.sealAppliesToRow(fingerprint: vouchee, renderedSender: "RAVI", senderPeerID: nil))
}
@Test
func aLookAlikeNameDoesBreakTheBinding() {
// The other half of that: the binding key is NFC, deliberately not
// NFKC. A fullwidth M merely LOOKS like M, so it is a different name
// and must break the binding — folding look-alikes is the collision
// resolver's job, which answers a different question.
let manager = makeManager()
announce(manager, vouchee, as: "Medic")
manager.setVerified(fingerprint: vouchee, verified: true)
announce(manager, vouchee, as: "\u{FF2D}edic")
#expect(manager.trustedNicknameMismatch(fingerprint: vouchee))
#expect(!manager.sealAppliesToRow(fingerprint: vouchee, renderedSender: "\u{FF2D}edic", senderPeerID: nil))
}
@Test
func onlyAnAsciiCollisionSuffixIsStripped() {
// `Character.isHexDigit` also accepts fullwidth digits, so a nickname
// literally ending in "#ABCD" was being truncated and could then
// match a baseline it is not. ASCII only, like `splitSuffix()`.
let manager = makeManager()
announce(manager, vouchee, as: "medic")
manager.setVerified(fingerprint: vouchee, verified: true)
#expect(manager.sealAppliesToRow(fingerprint: vouchee, renderedSender: "medic#a1b2", senderPeerID: decorator))
#expect(!manager.sealAppliesToRow(fingerprint: vouchee,
renderedSender: "medic#\u{FF21}\u{FF22}\u{FF23}\u{FF24}",
senderPeerID: decorator),
"a fullwidth tail is part of the name, not a suffix this device generates")
#expect(!manager.sealAppliesToRow(fingerprint: vouchee, renderedSender: "medic#zzzz", senderPeerID: nil),
"non-hex is part of the name too")
}
// 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")
#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))
}
@Test
func unverifyingClearsTheBaselineOnlyWhenNoVouchRemains() {
let manager = makeManager()
announce(manager, vouchee, as: "ravi")
manager.setVerified(fingerprint: vouchee, verified: true)
announce(manager, vouchee, as: "medic")
#expect(manager.trustedNicknameMismatch(fingerprint: vouchee))
manager.setVerified(fingerprint: vouchee, verified: false)
#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 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)
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 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 seals.
let manager = makeManager()
manager.setVerified(fingerprint: vouchee, verified: true)
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee))
announce(manager, vouchee, as: "medic")
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee))
}
@Test
func anEmptyClaimedNicknameIsNotAMismatch() {
let manager = makeManager()
announce(manager, vouchee, as: "ravi")
manager.setVerified(fingerprint: vouchee, verified: true)
announce(manager, vouchee, as: "")
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee))
}
// 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,11 @@ private final class TestIdentityManager: SecureIdentityStateManagerProtocol {
verified
}
// No name binding is exercised here: these tests drive peer-list assembly,
// not trust display.
func trustedNickname(fingerprint: String) -> String? { nil }
func trustedNicknameMismatch(fingerprint: String) -> Bool { false }
// MARK: Vouching (unused by these tests)
@discardableResult

View File

@ -0,0 +1,104 @@
import Foundation
import BitFoundation
import Testing
@testable import bitchat
/// The `#abcd` suffix exists so two peers claiming one nickname are
/// distinguishable. It is therefore only as good as the notion of "one
/// nickname" it counts with — anything that renders alike has to collide, or
/// the guard stays silent exactly when two rows look the same.
@MainActor
struct PeerDisplayNameResolverTests {
private let real = PeerID(str: "aaaa1111")
private let other = PeerID(str: "bbbb2222")
/// True when the resolver decided these two need telling apart.
private func suffixed(_ a: String, _ b: String,
connected: Bool = true, selfNickname: String = "me") -> Bool {
let out = PeerDisplayNameResolver.resolve(
[(peerID: real, nickname: a, isConnected: true),
(peerID: other, nickname: b, isConnected: connected)],
selfNickname: selfNickname)
return (out[real] ?? "").contains("#") || (out[other] ?? "").contains("#")
}
@Test
func namesThatRenderAlikeCollide() {
#expect(suffixed("Medic", "Medic"), "exact duplicate")
#expect(suffixed("Medic", "medic"), "case alone must not make two peers look distinct")
#expect(suffixed("Medic", "\u{FF2D}edic"), "fullwidth M")
#expect(suffixed("Medic", "\u{1D40C}edic"), "mathematical bold 𝐌")
#expect(suffixed("Medic", "\u{2133}edic"), "script ℳ")
#expect(suffixed("Medic", "\u{216F}edic"), "roman numeral Ⅿ")
#expect(suffixed("Jos\u{00E9}", "Jose\u{0301}"), "canonically equivalent spellings")
}
@Test
func unrelatedNamesDoNotCollide() {
#expect(!suffixed("Medic", "Zebra"))
#expect(!suffixed("Medic", "Medic2"))
#expect(!suffixed("Medic", ""))
}
@Test
func crossScriptHomoglyphsAreStillMissed() {
// Cyrillic М and Greek Ο have their own compatibility forms, so
// folding cannot reach them — that needs a UTS #39 confusables table.
// Pinned as a KNOWN gap so it is explicit rather than a surprise, and
// so whoever adds the table has a test to flip.
#expect(!suffixed("Medic", "\u{041C}\u{0435}dic"), "Cyrillic М + е: known gap")
#expect(!suffixed("Organizer", "\u{039F}rganizer"), "Greek Ο: known gap")
}
@Test
func onlyConnectedPeersAreSuffixed() {
// Unchanged by the folding: an offline namesake still gets no suffix,
// which is its own gap and not one this touches.
#expect(!suffixed("Medic", "medic", connected: false))
}
@Test
func ourOwnNicknameCountsTowardCollisions() {
// A remote peer claiming a folded variant of MY nickname must be
// suffixed, or it renders as me.
let out = PeerDisplayNameResolver.resolve(
[(peerID: other, nickname: "\u{FF2D}edic", isConnected: true)],
selfNickname: "Medic")
#expect((out[other] ?? "").contains("#"))
}
@Test
func theSenderDisplayNameResolverFoldsTheSameWay() {
// Collisions are counted in TWO places. This one feeds sender names on
// file transfers and had the same exact-match blind spot; folding only
// the peer-list resolver would have left it behind.
let mine = PeerID(str: "cccc3333")
func info(_ id: PeerID, _ nickname: String) -> BLEPeerInfo {
BLEPeerInfo(peerID: id, nickname: nickname, isConnected: true,
noisePublicKey: nil, signingPublicKey: nil,
isVerifiedNickname: true, lastSeen: Date())
}
let peers: [PeerID: BLEPeerInfo] = [
real: info(real, "Medic"),
other: info(other, "\u{FF2D}edic"),
]
let name = BLEPeerSenderDisplayName.resolveKnownPeer(
peerID: other, localPeerID: mine, localNickname: "me",
peers: peers, allowConnectedUnverified: true)
#expect(name?.contains("#") == true,
"a fullwidth namesake must be suffixed here too")
}
@Test
func theDisplayedNameIsNeverFolded() {
// Only the collision KEY folds. What is shown stays as its owner typed
// it, suffix aside.
let out = PeerDisplayNameResolver.resolve(
[(peerID: real, nickname: "Medic", isConnected: true),
(peerID: other, nickname: "\u{FF2D}edic", isConnected: true)],
selfNickname: "me")
#expect(out[real]?.hasPrefix("Medic") == true)
#expect(out[other]?.hasPrefix("\u{FF2D}edic") == true, "the fullwidth M is preserved on screen")
}
}