Fix security audit findings: 3 critical, 7 high

A broad audit surfaced ten critical/high issues across the crypto,
transport, identity, and panic-wipe layers. This fixes all ten.

Critical:
- Nostr DMs were unauthenticated. The NIP-17 seal was signed with a
  throwaway ephemeral key and the receiver never verified it, so anyone
  who knows a recipient's npub could forge messages (and delivery/read
  receipts) into an existing trusted conversation. The seal is now
  signed with the sender's real identity key, and the receiver verifies
  the seal signature and that seal.pubkey == rumor.pubkey.
  NOTE: this is a breaking wire-protocol change (see PR).
- Public BLE messages trusted registry membership instead of the packet
  signature. Since senderID is attacker-controlled, any verified peer
  could be impersonated in public chat. A valid signature from the
  claimed sender is now required before any registry identity is used.
- Unverified announces still persisted the announced identity, letting a
  replayed noisePublicKey overwrite a victim's stored signing key and
  nickname. persistIdentity is now gated on verification.

High:
- Noise decrypt trapped on a 16-19 byte ciphertext (negative prefix
  length after nonce extraction) — a remote crash. Now validated.
- Identity-cache debounce save used Timer.scheduledTimer on a GCD queue
  with no run loop, so it never fired; block/verify/favorite changes
  only persisted on explicit forceSave. Replaced with a
  DispatchSourceTimer on the queue; forceSave is now serialized.
- Identity-cache key load couldn't tell "missing" from a transient
  keychain failure and would regenerate (deleting) the key, orphaning
  the cache. Now uses getIdentityKeyWithResult and falls back to a
  session-only ephemeral key without clobbering the persisted key/cache.
- BLE receive-dedup key lacked a payload digest, so post-handshake
  flushes (queued msgs + delivery/read acks in the same ms) were dropped
  as duplicates. Digest added, matching the ingress registry.
- Maintenance timer was created only in init and never recreated after a
  panic stop/start, silently degrading the mesh until app restart. Now
  recreated in startServices.
- Panic wipe left persisted location state (selected channel, teleport
  set, bookmarks) and cached per-geohash Nostr private keys behind. Both
  are now cleared.
- Panic spawned an orphan NostrRelayManager instead of reusing .shared,
  splitting relay state from every other component. Now reuses .shared.

Tests updated to assert the fixed behavior (announce no longer persists
unverified identities; public messages require a signature; receive
dedup ID includes the payload digest).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-06-12 14:07:28 +02:00
co-authored by Claude Fable 5
parent fdf28aa5bb
commit ca63893197
13 changed files with 223 additions and 65 deletions
@@ -168,8 +168,13 @@ final class BLEAnnounceHandler {
env.updateTopology(peerID, neighbors)
}
// Persist cryptographic identity and signing key for robust offline verification
env.persistIdentity(announcement)
// Persist cryptographic identity and signing key for robust offline
// verification only for verified announces. Persisting unverified
// announces would let an attacker who replays a victim's noisePublicKey
// overwrite the victim's stored signing key/nickname (identity poisoning).
if verifiedAnnounce {
env.persistIdentity(announcement)
}
let announceBackID = "announce-back-\(peerID)"
let shouldSendBack = !env.dedupContains(announceBackID)
@@ -68,14 +68,29 @@ final class BLEPublicMessageHandler {
// Snapshot peers to avoid concurrent mutation while iterating during nickname collision checks.
let peersSnapshot = env.peersSnapshot()
// Public messages are always signed by their sender. `senderID` is
// attacker-controlled, so registry membership alone is NOT proof of
// identity a peer in the registry as "verified" could be impersonated
// by anyone spoofing their senderID. Require a valid packet signature
// from the claimed sender (our own echoes are exempt; they are matched
// by self-broadcast tracking below).
let isSelf = peerID == env.localPeerID()
let signedDisplayName = isSelf ? nil : env.signedSenderDisplayName(packet, peerID)
guard isSelf || signedDisplayName != nil else {
SecureLogger.warning("🚫 Dropping public message with missing/invalid signature for claimed sender \(peerID.id.prefix(8))", category: .security)
return
}
// Authenticity is established; prefer the registry's collision-resolved
// display name, then the signature-derived name.
guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
peerID: peerID,
localPeerID: env.localPeerID(),
localNickname: env.localNickname(),
peers: peersSnapshot,
allowConnectedUnverified: false
) ?? env.signedSenderDisplayName(packet, peerID) else {
SecureLogger.warning("🚫 Dropping public message from unverified or unknown peer \(peerID.id.prefix(8))", category: .security)
) ?? signedDisplayName else {
SecureLogger.warning("🚫 Dropping public message from unknown peer \(peerID.id.prefix(8))", category: .security)
return
}
@@ -12,7 +12,13 @@ struct BLEReceivedPacketContext: Equatable {
struct BLEReceivePipeline {
static func context(for packet: BitchatPacket, localPeerID: PeerID) -> BLEReceivedPacketContext {
let senderID = PeerID(hexData: packet.senderID)
let messageID = "\(senderID)-\(packet.timestamp)-\(packet.type)"
// Include a payload digest so that distinct packets sharing the same
// sender/timestamp(ms)/type are not collapsed as duplicates. The
// post-handshake flush sends queued messages, delivery and read receipts
// back-to-back within a single millisecond; without the digest every
// packet after the first would be silently dropped.
let digestPrefix = packet.payload.sha256Hash().prefix(4).hexEncodedString()
let messageID = "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)"
let messageType = MessageType(rawValue: packet.type)
let allowSelfSyncReplay = packet.ttl == 0 && senderID == localPeerID
let shouldDeduplicate = messageType != .fragment && !allowSelfSyncReplay
+23 -9
View File
@@ -234,15 +234,7 @@ final class BLEService: NSObject {
}
// Single maintenance timer for all periodic tasks (dispatch-based for determinism)
let timer = DispatchSource.makeTimerSource(queue: bleQueue)
timer.schedule(deadline: .now() + TransportConfig.bleMaintenanceInterval,
repeating: TransportConfig.bleMaintenanceInterval,
leeway: .seconds(TransportConfig.bleMaintenanceLeewaySeconds))
timer.setEventHandler { [weak self] in
self?.performMaintenance()
}
timer.resume()
maintenanceTimer = timer
startMaintenanceTimer()
// Publish initial empty state
requestPeerDataPublish()
@@ -435,7 +427,29 @@ final class BLEService: NSObject {
// MARK: Lifecycle
/// Creates and starts the periodic maintenance timer if it is not already
/// running. Idempotent so it can be called from both `init` and
/// `startServices()` the latter matters after a panic reset, where
/// `stopServices()` cancels and nils the timer.
private func startMaintenanceTimer() {
guard maintenanceTimer == nil else { return }
let timer = DispatchSource.makeTimerSource(queue: bleQueue)
timer.schedule(deadline: .now() + TransportConfig.bleMaintenanceInterval,
repeating: TransportConfig.bleMaintenanceInterval,
leeway: .seconds(TransportConfig.bleMaintenanceLeewaySeconds))
timer.setEventHandler { [weak self] in
self?.performMaintenance()
}
timer.resume()
maintenanceTimer = timer
}
func startServices() {
// Restart the maintenance timer if a prior stopServices() cancelled it
// (e.g. the panic flow), otherwise periodic announces, peer reconciliation
// and cache cleanup would never resume until app restart.
startMaintenanceTimer()
// Start BLE services if not already running
if centralManager?.state == .poweredOn {
centralManager?.scanForPeripherals(