docs/security-review-jul-27.md
Commit: 92d07b22 (worktree: ~/.opencode/worktrees/bitchat-security-review)
Scope: Exploitable bugs, privacy violations, tracking opportunities, DoS vectors.
Method: Read-only static review of crypto/, noise/, identity/, mesh/, protocol/, net/, service/, nostr/, geohash/, ui/, features/, manifest & build config. All findings verified against source.
noise/southernstorm/protocol/SymmetricState.java:135-159 (also 98-104, 168-182)
mixKey() logs the raw X25519 shared secret ("Input data"), current and new chaining keys in hex. proguard-rules.pro has no assumenosideeffects for android.util.Log, so this reaches the release APK. Anyone with the logcat transcript of a handshake can recompute split() outputs and fully decrypt the transport session. Release-blocking.
protocol/BinaryProtocol.kt:429-454, protocol/CompressionUtil.kt:75-118
For compressed v2 packets the decoder reads a 4-byte attacker-controlled originalSize and immediately does ByteArray(originalSize) — up to 2 GiB per packet. The ratio guard is skipped when compressedSize == 0 and is 50,000:1 anyway (real deflate max ≈ 1032:1). Runs inline on the GATT callback path; any nearby unauthenticated BLE peer can OOM/crash the foreground service with tiny writes.
noise/NoiseSession.kt:69-106
Bit indexing is LSB-first, but the window-shift code right-shifts the byte string. A replayed captured frame (nonce 0) tests a zero bit and is accepted; only the single most-recent nonce is reliably blocked. A passive BLE observer can replay DMs, ACKs, and receipts.
mesh/AnnouncementIdentityValidator.kt:13-36, mesh/SecurityManager.kt:282-313, mesh/MessageHandler.kt:265-299
An attacker can replay a victim's Noise public key (→ victim's peerID) inside an announcement signed with the attacker's Ed25519 key. On fresh installs or for never-authenticated peers the forged binding wins, and the genuine victim's later announce is rejected as "key replacement".
mesh/MessageHandler.kt:88 (also 125; nostr/NostrProtocol.kt:277)
Log.d logs the first 30 chars of every decrypted E2E private message plus sender peerID. Not stripped in release. Defeats E2E guarantees for any logcat reader. Related: ui/DataManager.kt:161-196 dumps the full favorite/fingerprint social graph at startup (Low).
ui/NotificationManager.kt:224-246
Decrypted DM bodies (mesh + Nostr) are posted with full text in BigTextStyle/InboxStyle; no VISIBILITY_SECRET / lockscreen visibility set. Content renders on lock screen and to any notification-listener app; no opt-out.
nostr/GeohashMessageHandler.kt:44-105, nostr/LocationNotesManager.kt:358-414, nostr/NostrRelayManager.kt:663-706
NostrEvent.isValidSignature() (exists, NostrEvent.kt:171) is never called for kind 20000/20001/1. A malicious/compromised relay (auto-selected from a daily third-party list, see M9) can inject events with arbitrary pubkeys — impersonating any user in any geohash channel, spoofing participants, caching attacker-chosen nicknames, registering DM aliases.
nostr/NostrDirectMessageHandler.kt:134-138,165-175, nostr/NostrTransport.kt:316-384
Any decryptable gift-wrapped DM triggers an automatic signed DELIVERED ack. For geohash DMs the ack uses the public deterministic per-geohash identity, so anyone can probe a target's known geohash pubkey and confirm the device is online now, and chart activity patterns. No setting gates this.
geohash/LocationChannel.kt:8 (precision 8 ≈ 19×38 m), nostr/LocationNotesManager.kt:119-165
Kind-1 (relay-archived, persistent) notes carry the 8-char geohash, exact created_at, and an optional plaintext nickname, signed by a stable pubkey — a permanent public record "pubkey X was within ~30 m of this spot at time T". REQ #g filters also disclose precise location to relays.
nostr/NostrIdentity.kt:137-177, ui/GeohashViewModel.kt:184-193
Geohash identity = HMAC(deviceSeed, geohash), never rotated. A passive observer of a channel can recognize returning users months later and assemble their full history in that channel; the plaintext ["n", nickname] tag (nostr/NostrProtocol.kt:167-169) links the same person across channels.
mesh/PacketProcessor.kt:43-95, mesh/BluetoothPacketBroadcaster.kt:122-124
A new coroutine actor with Channel.UNLIMITED is created per attacker-chosen senderID before any security validation, with no eviction. Spraying packets with random sender IDs grows live coroutines + unbounded channels forever.
mesh/SecurityManager.kt:268-280, mesh/PacketRelayManager.kt:59-107,134-163
Signature verification enforced only for ANNOUNCE/MESSAGE/FILE_TRANSFER/LEAVE. FRAGMENT, REQUEST_SYNC, NOISE_* verify unconditionally and are relayed (unconditionally at TTL ≥ 4) with no rate limit. One BLE radio can make the entire mesh re-broadcast forged traffic, draining bandwidth and battery.
mesh/BluetoothMeshService.kt:671-676, sync/GossipSyncManager.kt:168-199
A spoofed REQUEST_SYNC with an empty (forgeable) GCS filter makes a victim dump its entire sync cache onto the radio; repeating keeps neighbors transmitting continuously. No rate limit or response budget.
mesh/SecurityManager.kt:78-90, mesh/BluetoothMeshService.kt:586-621
Duplicate ANNOUNCEs are re-accepted at TTL ≥ 7; direct-link is inferred from TTL alone (excluded from the signature, attacker-settable). Replaying a victim's recent ANNOUNCE at TTL=7 repeatedly tears down the victim's Noise sessions, breaking in-flight DMs.
noise/southernstorm/protocol/HandshakeState.java:750-762,1034-1040,1050-1068
Only the all-zero ephemeral key is rejected; other low-order Curve25519 points pass, DH outputs never checked, remote static key unvalidated. A malicious identity with low-order keys yields all-zero DH outputs → publicly derivable session keys. SecureIdentityStateManager.validatePublicKey() (identity/…:403-417) is debug-only and its blocklist misses real low-order points.
noise/NoiseSessionManager.kt:228-252,312-316
Any frame from any spoofed peerID creates a responder session (~3 X25519 scalar mults) inside a @Synchronized method, with no cap on half-open responder sessions (staleness check only on the initiate path).
mesh/BluetoothGattServerManager.kt:400-412, mesh/BluetoothMeshService.kt:54
The scan response embeds the truncated fingerprint of the persistent static Noise identity, surviving restarts and MAC rotation (by design, for dedup). Any passive sniffer can track a user across time/place. Nickname is broadcast in every ANNOUNCE; ANNOUNCE gossip TLVs disclose the user's neighbor graph.
mesh/FragmentManager.kt:32,202-263
Reassembly keyed by 8-byte fragment ID only (not sender). Fragments are unsigned; an attacker injects one colliding fragment to destroy a victim's in-flight (up to 1 MB) transfer. Bounded by existing caps; DoS/corruption, not forgery.
crypto/EncryptionService.kt:505-524
Old plaintext key is deleted only if the encrypted store doesn't already have one; otherwise the plaintext private key remains on disk indefinitely.
ui/media/ImageMessageItem.kt:74, ui/media/FullScreenImageViewer.kt:75, ui/MessageComponents.kt:294-304
Received images decoded with BitmapFactory.decodeFile — no bounds check, no inSampleSize, on the main thread during composition. A small PNG with huge dimensions → instant OOM when the chat renders. readBytes() re-reads whole files on every recomposition.
features/file/FileUtils.kt:194-263, nostr/NostrDirectMessageHandler.kt:191-194
Nostr DM path accepts up to 10 MB per message (AppConstants.kt:71); a malicious contact can fill cacheDir indefinitely. Filenames are sanitized (path traversal verified not exploitable).
AndroidManifest.xml:98-115, MainActivity.kt:828-894
Any app can fire intents with EXTRA_OPEN_PRIVATE_CHAT/EXTRA_PEER_ID to open arbitrary chat sheets and silently clear the victim's pending notifications, or trigger the verification sheet UI. QR payload itself is cryptographically validated — no verification forgery.
nostr/RelayDirectory.kt:29,152-191
Compromise of permissionlesstech/georelays steers all users to attacker relays → precise #g filters + forged-event injection (H5).
nostr/NostrRelayManager.kt:131-147, nostr/RelayDirectory.kt:88-106, nostr/NostrFilter.kt:37-71
Each relay learns subscribed cells; #p DM filters reveal owned pubkeys. Mitigated: Tor ON by default, fail-closed proxy config (net/ArtiTorManager.kt:151,224).
nostr/NostrRelayManager.kt:107,286-288,862-871
messageQueue entries are never removed after send; every relay reconnect re-sends full history (duplicate gift wraps, memory growth, extra metadata).
nostr/GeohashRepository.kt:22-29,61,104-118
geohashParticipants, geoNicknames, etc. grow per unique pubkey with no eviction; forged events from unlimited fresh pubkeys exhaust memory. No WebSocket frame size cap (NostrRelayManager.kt:874-876).
nostr/NostrCrypto.kt:211-260,267-293
Raw XChaCha20-Poly1305 over unpadded UTF-8; relays/observers see exact DM lengths (receipt vs message vs file) and it's incompatible with real NIP-44 clients. AEAD itself sound.
mesh/PeerManager.kt:93,228-240,551-564
No cap on the peers map; cheap self-signed ANNOUNCEs create verified entries and can each trigger Noise handshakes. 3-minute sweep is the only bound.
mesh/BluetoothGattServerManager.kt:232-277, mesh/BluetoothGattClientManager.kt:644-656
Every write is fully decoded/verified with no per-connection or global rate limit; combined with H9 guarantees backlog growth.
crypto/EncryptionService.kt:485-503 vs identity/SecureIdentityStateManager.kt:145-195
Different keys in different pref files; same device presents two signing identities; panic wipe doesn't rotate both. Also: EncryptionService.sign() returns an empty signature and verify() ignores its inputs entirely (EncryptionService.kt:226-242) — a latent trap.
mesh/BLEPacketPaddingPolicy.kt:11-17, protocol/BinaryProtocol.kt:42-43,76).mesh/SecurityManager.kt:55-73,101).crypto/EncryptionService.kt:466-483, noise/NoiseEncryptionService.kt:85-119).noise/NoiseChannelEncryption.kt:148-168,203-243).geohash/OpenStreetMapGeocoderProvider.kt:21-29).#p filters announce pubkey ownership to relays — mitigated by randomized gift-wrap timestamps (nostr/NostrIdentity.kt:108-128).sub-<millis>-<rand>) aid per-session correlation (nostr/NostrRelayManager.kt:809-811).created_at. Sender spoofing inside gift wraps is correctly prevented (nostr/NostrProtocol.kt:77-96).MainActivity.kt:84-86).ui/ChatUserSheet.kt:87, ui/SecurityVerificationSheet.kt:411).evaluateJavascript — activity not exported, limited impact (ui/GeohashPickerActivity.kt:104-148).packet.recipientID != SpecialRecipients.BROADCAST is ByteArray reference comparison — always true (mesh/BluetoothPacketBroadcaster.kt:340).mesh/BluetoothGattServerManager.kt:232-239, mesh/PacketProcessor.kt:127).allowBackup="false" + backup rules.seal.pubkey == rumor.pubkey enforced; Schnorr nonces use SecureRandom.SymmetricState, MessageHandler.kt:88); add ProGuard assumenosideeffects for android.util.Log. (C1, H3)originalSize in CompressionUtil.decompress (~1–2 MB); tighten ratio to ≈1032:1. (C2)mixDH. (H1, M1)isValidSignature() on all incoming Nostr events; pin/sign the relay directory. (H5, M9)messageQueue (drain after send) and GeohashRepository caches. (M11, M12)