#1820

Warn in the chat when bitchat refuses a different key for a contact

OpenRodsF1wants to merge intofrom

Route 2 from #1661, built to the outline in the closing comment there.

What changes

  • Transport. BLEAnnounceHandler now reports its two signing-key rejections through a new reportRefusedSigningKey environment closure: the trust-on-first-use pin (.pinnedKey) and the key bound inside an authenticated Noise session (.authenticatedKey). keyMismatch stays log-only. BLEService turns the report into TransportEvent.signingKeyRefused(peerID:noisePublicKey:signingPublicKey:reason:) through emitTransportEvent, so it takes the same panic-gated main-actor hop as other transport events.
  • Chat. ChatViewModel routes the event to ChatPeerIdentityCoordinator.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

Activity

3
chatgpt-codex-connector[bot]5d ago

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

ReviewStatusCommitReview trigger
📝 Code Review⚠️ Failed 2026-10-26T00:14:51.068887Z
We couldn’t complete this review. Comment @codex review to try again.
7864808PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.