diff --git a/bitchat/Identity/SecureIdentityStateManager.swift b/bitchat/Identity/SecureIdentityStateManager.swift index 2b7f69a4..fe6eb9c8 100644 --- a/bitchat/Identity/SecureIdentityStateManager.swift +++ b/bitchat/Identity/SecureIdentityStateManager.swift @@ -137,9 +137,6 @@ protocol SecureIdentityStateManagerProtocol { /// Whether this peer now announces a different nickname than the one its /// trust was earned under. func trustedNicknameMismatch(fingerprint: String) -> Bool - /// Verified AND still presenting the name it was verified under — what a - /// seal beside a LIVE name is actually asserting. One lock, not two. - func isVerifiedAndNameBound(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) -> Bool @@ -164,15 +161,11 @@ protocol SecureIdentityStateManagerProtocol { } extension SecureIdentityStateManagerProtocol { - // Default implementations so a conformance only has to provide the two - // primitives. `SecureIdentityStateManager` overrides both to answer in a - // single lock acquisition — these seal checks run per row, per render, and - // the format cache does not shield the one in ChatMessageFormatter because - // the answer is part of its cache key. - func isVerifiedAndNameBound(fingerprint: String) -> Bool { - isVerified(fingerprint: fingerprint) && !trustedNicknameMismatch(fingerprint: fingerprint) - } - + // 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) -> Bool { guard isVerified(fingerprint: fingerprint) else { return false } guard let pinned = trustedNickname(fingerprint: fingerprint), !pinned.isEmpty @@ -802,13 +795,6 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { queue.sync { cache.trustedNicknames?[fingerprint] } } - func isVerifiedAndNameBound(fingerprint: String) -> Bool { - queue.sync { - guard cache.verifiedFingerprints.contains(fingerprint) else { return false } - return !liveNameMismatchLocked(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 diff --git a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift index fbe8338e..0d6d92b8 100644 --- a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift @@ -477,9 +477,16 @@ final class ChatPeerIdentityCoordinator { /// invalidated by verification and session changes, never by a rename, so a /// demotion written into the cache would be both stale and sticky. The /// session really is Noise-secured; only the claim about *who* is weakened, - /// which is exactly the difference between the two cases. Nothing branches - /// on `.noiseVerified` beyond the glyph — every behavioural site treats it - /// and `.noiseSecured` alike. + /// 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, diff --git a/bitchatTests/Mocks/MockIdentityManager.swift b/bitchatTests/Mocks/MockIdentityManager.swift index 2e1fc12d..8d00187b 100644 --- a/bitchatTests/Mocks/MockIdentityManager.swift +++ b/bitchatTests/Mocks/MockIdentityManager.swift @@ -107,7 +107,11 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol { func trustedNicknameMismatch(fingerprint: String) -> Bool { guard let pinned = trustedNicknames[fingerprint], !pinned.isEmpty, let claimed = claimedNicknames[fingerprint], !claimed.isEmpty else { return false } - return pinned != claimed + // 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 { diff --git a/bitchatTests/Services/SecureIdentityStateManagerNicknameBindingTests.swift b/bitchatTests/Services/SecureIdentityStateManagerNicknameBindingTests.swift index adef9eb1..f582041b 100644 --- a/bitchatTests/Services/SecureIdentityStateManagerNicknameBindingTests.swift +++ b/bitchatTests/Services/SecureIdentityStateManagerNicknameBindingTests.swift @@ -265,17 +265,17 @@ struct SecureIdentityStateManagerNicknameBindingTests { } @Test - func theCombinedLiveQueryAgreesWithItsParts() { + 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") - #expect(!manager.isVerifiedAndNameBound(fingerprint: vouchee), "not verified yet") - manager.setVerified(fingerprint: vouchee, verified: true) - #expect(manager.isVerifiedAndNameBound(fingerprint: vouchee)) #expect(manager.trustedNickname(fingerprint: vouchee) == "ravi") announce(manager, vouchee, as: "medic") - #expect(!manager.isVerifiedAndNameBound(fingerprint: vouchee)) + #expect(manager.trustedNicknameMismatch(fingerprint: vouchee)) #expect(manager.trustedNickname(fingerprint: vouchee) == "ravi", "the baseline is what the sheet needs to name") } @@ -292,7 +292,6 @@ struct SecureIdentityStateManagerNicknameBindingTests { announce(manager, vouchee, as: "ravi") #expect(!manager.trustedNicknameMismatch(fingerprint: vouchee)) - #expect(manager.isVerifiedAndNameBound(fingerprint: vouchee)) #expect(manager.sealAppliesToRow(fingerprint: vouchee, renderedSender: "RAVI")) }