#1820
Warn in the chat when bitchat refuses a different key for a contact
Route 2 from #1661, built to the outline in the closing comment there.
What changes
- Transport.
BLEAnnounceHandlernow reports its two signing-key rejections through a newreportRefusedSigningKeyenvironment closure: the trust-on-first-use pin (.pinnedKey) and the key bound inside an authenticated Noise session (.authenticatedKey).keyMismatchstays log-only.BLEServiceturns the report intoTransportEvent.signingKeyRefused(peerID:noisePublicKey:signingPublicKey:reason:)throughemitTransportEvent, so it takes the same panic-gated main-actor hop as other transport events. - Chat.
ChatViewModelroutes the event toChatPeerIdentityCoordinator.warnOfRefusedSigningKey, which adds a system notice to the chat already held with that contact: the open chat if it is theirs, otherwise the first with messages under the mesh ID, the full Noise key, or the bridged Nostr key. Nothing is migrated or trusted, and with no chat nothing is created. The chat is marked unread unless it is open. - Name. The notice names the contact through
resolveNickname(for:), from the local petname, the registry, or the persisted identity. The refused announce reached none of them, so the nickname it carried can't appear. - De-dup. The notice's ID is a fixed prefix plus the refused key, so the conversation store's per-chat ID index keeps it to one notice per offending key, with no new state to track or wipe. Signing keys cost nothing to generate, so per-key de-dup alone would let a stream of fresh keys fill the chat. No notice is added while the newest message already is one.
- Copy. New key
system.identity.signing_key_refused, translated into all 30 locales: "someone presented a different key for %@, and bitchat refused it. this chat keeps the key it already had." The translator comment says to keep it as refused, not changed.
Left log-only on purpose: the registry's in-barrier refusal in upsertVerifiedAnnounce. It fires only when another announce wrote the pin between the trust check and the barrier. An established contact is caught by the trust check itself, which now reports. It's a one-line addition if you'd rather cover the race too.
Tests
- Handler, on the real rejection paths (modelled on
signingKeyMismatchWithPinnedKeySkipsUpsertAndIdentityPersistence): both rejections report the refused key and their reason; a Noise-key mismatch and a verified announce report nothing. - Service: an announce that replays a seeded peer's Noise identity with another, validly self-signed signing key, injected into a real
BLEService, reaches the event delegate as.signingKeyRefused. - View model: the event lands in the contact's chat.
- Coordinator: one notice per key under the known name; fresh keys can't flood the chat; nothing is created without a chat; an open chat stays read; chats held under the Noise or Nostr key are found.
Each test fails when its behavior is reverted (12 mutations, one per behavior). Locally, with xcodebuild, the full suites pass: macOS (2,211 Swift Testing and 214 XCTest tests) and the iOS 26.5 simulator (2,214 and 214).
🤖 Generated with Claude Code
Codex Review Summary
This comment shows the latest Codex review activity on this pull request.
We couldn’t complete this review. Comment
@codex reviewto try again.7864808ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.