Compare commits

..
Author SHA1 Message Date
jack e1bf069c74 Favorites: remove npub resend tracking and nickname-based key migration; rely on Noise key as identity 2025-09-12 14:33:15 +02:00
jack 2e43770d0e Favorites: mesh-only system message; stop reconnect resends; gate system on state change 2025-09-12 14:33:08 +02:00
4b0634d1d0 Fix/general queue (#580)
* Fix emote targeting and grammar; add tests

Prevent 'system' mis-target via peerID-derived display name in actions sheet. Correct /hug and /slap usage/error grammar by passing base command name. Improve geohash nickname resolution to match displayName with #suffix. Add CommandProcessor tests.

* Geohash ordering: strict in-order inserts and timestamp clamp

Use channel-aware late-insert threshold with 0s for geohash to keep strict chronological order. Clamp future Nostr event timestamps to 'now' to avoid future-dated items skewing order in geohash timelines.

* Trim trailing/leading spaces in geohash nicknames and tag emission

Sanitize Nostr 'n' tag values on ingest and emit by trimming whitespace/newlines to prevent trailing spaces in displayed usernames. Local nickname is already trimmed on focus loss and submit.

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-09-11 21:02:48 +02:00
jack 97cbe37f09 Project: add OSLog+Categories.swift to iOS/macOS targets to fix missing OSLog categories (noise/security/keychain/etc.) 2025-09-11 20:14:00 +02:00
jack b5d6e4eeb5 Merge branch 'pr/575' 2025-09-11 20:07:04 +02:00
islam a1edf29bd3 Remove unnecessary import os.logs 2025-09-11 19:03:08 +01:00
islam 5f402698a1 Extract private functions into a separate extension 2025-09-11 19:03:08 +01:00
islam 1e997e1387 Statically typed logging of KeyOperations 2025-09-11 19:03:08 +01:00
islam e602024617 Remove dead code 2025-09-11 19:03:08 +01:00
islam deb464f5d8 Remove redundant .noise 2025-09-11 19:03:08 +01:00
islam e5a415d885 Overloading .debug/.error for ‘.logSecurityEvent’
Search/Replace Strategies:

1.
Search regex: `SecureLogger\.logSecurityEvent\(\s*(.*?),\s*level:\s*\.(\w+)\s*\)`
Replace regex: `SecureLogger.$2($1)`

Sample input:
`SecureLogger.logSecurityEvent(.authenticationFailed(peerID: peerID), level: .warning)`

Sample output:
`SecureLogger.warning(.authenticationFailed(peerID: peerID))`

---

2.
Search regex: `SecureLogger\.logSecurityEvent\(\s*(.*?)\s*\)`
Replace regex: `SecureLogger.info($1)`  (`info` is the default level)

Sample input:
`SecureLogger.logSecurityEvent(.handshakeStarted(peerID: peerID))`

Sample output:
`SecureLogger.info(.handshakeStarted(peerID: peerID))`
2025-09-11 19:03:08 +01:00
islam b5382b129e Rename logError(…) to error(…) 2025-09-11 19:03:08 +01:00
islam 5d6aecfc83 Replace .log w/ explicit .debug/.error functions
This would make the intention more explicit so we can overload different logging types as well like keychain, security events, etc…

Search/Replace Strategies:

1.
Search regex: `SecureLogger\.log\(\s*(.*?),\s*category:\s*(.*?),\s*level:\s*\.(\w+)\s*\)`
Replace regex: `SecureLogger.$3($1, category: $2)`

Sample input:
```
SecureLogger.log(
    "🔄 Found favorite for '\(peerInfo.nickname)' by nickname, updating noise key",
    category: .session,
    level: .debug
)
```

Sample output:
`SecureLogger.debug("🔄 Found favorite for '\(peerInfo.nickname)' by nickname, updating noise key", category: .session)`

---

2.
Search regex: `SecureLogger\.log\((.*?)\)`
Replace regex: `SecureLogger.debug($1)` (as it’s the default level)

Sample input:
`SecureLogger.log("some text")`

Sample output:
`SecureLogger.debug("some text")`

---

3
Manual changes:
ChatViewModel line 5393 (commented code)
NostrRelayManager line 196 (commented code)
NostrRelayManager lines 346-350 (if/else logic)
NostrRelayManager line 371 (commented code)
2025-09-11 19:03:08 +01:00
islam 5ca9222fc2 Make logging categories static properties of OSLog
So we can use `.<category name>` to simplify the code.

Search/Replace Strategy:
Search text: `category: SecureLogger.`
Replace text: `category: .`
2025-09-11 19:02:32 +01:00
jack ee16ff5ff4 Fix Nostr subscriptions: connect on subscribe; handle inbound frames correctly\n\n- Call ensureConnections(to:) in subscribe() so queued REQs open sockets immediately and flush after ping\n- Correct ParsedInbound failable initializer to return parsed EVENT/EOSE/OK/NOTICE instead of always nil\n- Improves chat receipt of geohash and DM events after Tor readiness 2025-09-11 19:53:19 +02:00
IslamandGitHub 2f83433247 Refactor parsing inbound messages (#577)
* DRY + helper extension to get data from Message

* Flatten guard > do > if > switch + use `try?`

* Use failable init instead of a global function
2025-09-11 19:18:06 +02:00
IslamandGitHub e72fe50ffa Perf: Add final to classes that are not inherited (#574) 2025-09-11 19:17:04 +02:00
IslamandGitHub 56f1c37129 Regenerate info.plist by adding the missing keys (#576)
`xcodegen` probably uses alphabetical sorting so had to commit the info.plist to avoid discrepancies
2025-09-11 19:14:27 +02:00
IslamandGitHub 2ade3a3300 Add TransportConfig to bitchatShareExtension target (#579)
ShareViewController uses TransportConfig and without this target membership the code doesn’t compile
2025-09-11 19:13:54 +02:00
5f44af19da tor by default, small (#564)
* feat(tor): Tor-by-default scaffold and integration

- Add TorManager with static/dlopen start, torrc generation, SOCKS probe
- Add TorURLSession; route Nostr/Web fetches via SOCKS proxy
- Add chat system messages for Tor status; show progress (macOS) and ready
- Disable ControlPort bootstrap monitor on iOS; keep it on macOS
- Make Tor waits non-blocking; avoid main-actor stalls on startup
- Queue & flush Nostr subscriptions on relay connect; skip duplicates
- Always rewrite torrc at launch to fix iOS container path mismatches
- Link libz; add project wiring for tor-nolzma.xcframework
- Minor fixes: SOCKS probe resumeOnce guard, entitlement for network.server (macOS)

* iOS: deterministic Tor recovery + 100% gating; BLE-first; session rebuild

- Restart/wake Tor on foreground via ControlPort (ACTIVE/SHUTDOWN),
  avoid restarts during bootstrap; add NWPathMonitor to trigger checks
- Use NWConnection control polling for GETINFO; remove blocking CFStream
  readers to avoid QoS inversions; compute readiness from SOCKS + 100%
- Rebuild TorURLSession on resume; reset Nostr connections to rebind
- Gate all internet after full bootstrap; keep BLE mesh startup fast
- Fix Swift 6 capture issues; hop UI updates to @MainActor
- Remove Tor progress spam; persist initial "starting tor..." system message

* UI: show Tor system messages only in geohash channels (not mesh)

- Gate "starting tor..." and readiness/timeout messages to geohash view
- Add helper addGeohashOnlySystemMessage() to avoid posting to mesh timeline
- Persist system messages in geohash backing store via addPublicSystemMessage()

* Relays: treat repeated -1011 handshake failures as permanent; skip reconnects

- Classify NSURLErrorBadServerResponse as permanent and stop retrying
- Filter permanently-failed relays from subscribe/connect attempts
- Avoid reconnect scheduling for permanently failed relays

* Embed Tor via tor_api; deterministic restart + Nostr gating; add Tor notifications

- Run Tor via tor_api in a dedicated thread with OwningControllerFD
- Cleanly stop Tor on background; restart on .active (single instance)
- Avoid fallback to tor_main/dlopen; add is-running check to prevent duplicates
- Fix argv lifetime in C glue to avoid strcmp crash on start
- Gate Nostr connect/subscribe/send until Tor is fully ready
- Rebuild URLSession + reset relays after Tor readiness (scene-based)
- Remove TorDidBecomeReady double-reset and appDidBecomeActive resubscribe
- Add TorWillRestart/TorDidBecomeReady notifications and chat system messages
- Debounce path-change restarts; ACTIVE poke first; coalesce subs; cancel stale reconnect timers
- Project: add CTorHost.c and TorNotifications.swift to targets; fix libz.tbd path

* Defer Nostr setup logs until Tor is ready; fix subscribe coalescing and reconnect generation

- Move "Connecting to Nostr relays" log after awaitReady()
- Log "Queuing subscription" when Tor not ready; only coalesce when handler exists
- Clear coalescer on unsubscribe
- Cancel stale reconnect timers using connectionGeneration
- Remove app-level TorDidBecomeReady reset to avoid duplicate reconnects
- Debounce path-change restarts

* Gate Nostr init/subscription logs until Tor is ready

- ChatViewModel: await Tor readiness before initializing Nostr and logging
- Only log GeoDM subscription when Tor is ready to avoid early noise

* Make Nostr connect single-sourced; defer DM subscription until connected

- Remove duplicate connect call from ChatViewModel; let scene-based flow connect
- Setup DM subscription once on first connection via  sink
- Reduce early subscription send/cancel noise after Tor restarts

* On launch, queue Nostr subscriptions without initiating connects; let centralized connect handle it

- In subscribe(), if no connections exist, just list relays and queue subs
- Avoids early send/cancel churn before connect() runs post-Tor-ready

* Always queue subscriptions and flush on connection; avoid immediate sends

- Prevents early send/cancel churn at launch and during reconnects
- If relays are already connected, flush immediately; otherwise pending until connected

* UI: scope Tor restart messages to geohash channels; skip initial foreground restart on cold launch to avoid confusing system message in #mesh

* geo: disable background sampling + notifications\n- Gate sampling to foreground only (beginGeohashSampling, watchers)\n- Suppress geohash activity notifications unless app is active\n- Stop sampling explicitly on background scene phase

* Update BitchatApp.swift

Co-authored-by: asmo <asmogo@protonmail.com>

* Update BitchatApp.swift

Co-authored-by: asmo <asmogo@protonmail.com>

* Update BitchatApp.swift

Co-authored-by: asmo <asmogo@protonmail.com>

* Update bitchat/BitchatApp.swift

Co-authored-by: asmo <asmogo@protonmail.com>

* Update bitchat/BitchatApp.swift

Co-authored-by: asmo <asmogo@protonmail.com>

* fix(iOS App): resolve merge artifacts in scenePhase handler\n- Remove duplicate didEnterBackground state\n- Fix switch/if braces and logic for foreground restart gating

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: asmo <asmogo@protonmail.com>
2025-09-11 19:08:43 +02:00
lollerfirstandGitHub 6e1fb15edf feat: weekly update bundled georelays (#524)
* update bundled georelays

* fix typo
2025-09-11 13:30:33 +02:00
IslamandGitHub 9166c9e28b Update iOS App Icon to the new single-size format (#573) 2025-09-11 11:15:57 +02:00
52 changed files with 757 additions and 823 deletions
+40
View File
@@ -0,0 +1,40 @@
name: Fetch GeoRelays Data
on:
schedule:
- cron: '0 6 * * 0'
workflow_dispatch:
permissions:
contents: write
jobs:
update-relay-data:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Fetch GeoRelays
run: |
wget https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
mv nostr_relays.csv ./relays/online_relays_gps.csv
- name: Check for changes
id: git-check
run: |
git diff --exit-code || echo "changes=true" >> $GITHUB_OUTPUT
- name: Commit and push changes
if: steps.git-check.outputs.changes == 'true'
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add relays/online_relays_gps.csv
git commit -m "Automated update of relay data - $(date -u)"
git push
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+3 -3
View File
@@ -37,7 +37,7 @@ This three-message pattern provides:
#### NoiseEncryptionService #### NoiseEncryptionService
The main service managing all Noise operations: The main service managing all Noise operations:
```swift ```swift
class NoiseEncryptionService { final class NoiseEncryptionService {
private let staticIdentityKey: Curve25519.KeyAgreement.PrivateKey private let staticIdentityKey: Curve25519.KeyAgreement.PrivateKey
private let sessionManager: NoiseSessionManager private let sessionManager: NoiseSessionManager
private let channelEncryption = NoiseChannelEncryption() private let channelEncryption = NoiseChannelEncryption()
@@ -47,7 +47,7 @@ class NoiseEncryptionService {
#### NoiseSession #### NoiseSession
Individual session state for each peer: Individual session state for each peer:
```swift ```swift
class NoiseSession { final class NoiseSession {
private var handshakeState: NoiseHandshakeState? private var handshakeState: NoiseHandshakeState?
private var sendCipher: NoiseCipherState? private var sendCipher: NoiseCipherState?
private var receiveCipher: NoiseCipherState? private var receiveCipher: NoiseCipherState?
@@ -58,7 +58,7 @@ class NoiseSession {
#### NoiseSessionManager #### NoiseSessionManager
Thread-safe session management: Thread-safe session management:
```swift ```swift
class NoiseSessionManager { final class NoiseSessionManager {
private var sessions: [String: NoiseSession] = [:] private var sessions: [String: NoiseSession] = [:]
private let sessionsQueue = DispatchQueue(label: "noise.sessions", attributes: .concurrent) private let sessionsQueue = DispatchQueue(label: "noise.sessions", attributes: .concurrent)
} }
+19 -7
View File
@@ -39,10 +39,8 @@
0481A3592E6D929E00FC845E /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A3572E6D929E00FC845E /* tor-nolzma.xcframework */; }; 0481A3592E6D929E00FC845E /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A3572E6D929E00FC845E /* tor-nolzma.xcframework */; };
0481A35B2E6D9BEF00FC845E /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35A2E6D9BEF00FC845E /* libz.tbd */; }; 0481A35B2E6D9BEF00FC845E /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35A2E6D9BEF00FC845E /* libz.tbd */; };
0481A35D2E6DA18600FC845E /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35C2E6DA18600FC845E /* libz.tbd */; }; 0481A35D2E6DA18600FC845E /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35C2E6DA18600FC845E /* libz.tbd */; };
0C0EFA112E6EAAAA00ABCDEF /* CTorHost.c in Sources */ = {isa = PBXBuildFile; fileRef = 0C0EFA102E6EAAAA00ABCDEF /* CTorHost.c */; }; 0481A3902E734CAE00FC845E /* CommandProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A38F2E734CAE00FC845E /* CommandProcessorTests.swift */; };
0C0EFA122E6EAAAA00ABCDF0 /* CTorHost.c in Sources */ = {isa = PBXBuildFile; fileRef = 0C0EFA102E6EAAAA00ABCDEF /* CTorHost.c */; }; 0481A3912E734CAE00FC845E /* CommandProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A38F2E734CAE00FC845E /* CommandProcessorTests.swift */; };
0C0EFA162E6EAABB00ABCDF4 /* TorNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0EFA132E6EAABB00ABCDF1 /* TorNotifications.swift */; };
0C0EFA172E6EAABB00ABCDF5 /* TorNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0EFA132E6EAABB00ABCDF1 /* TorNotifications.swift */; };
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; }; 048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; }; 048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
048A4BE92E5CCCC300162C4B /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; }; 048A4BE92E5CCCC300162C4B /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
@@ -76,6 +74,10 @@
049BD3B52E51F319001A566B /* MessageRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3B02E51F319001A566B /* MessageRouter.swift */; }; 049BD3B52E51F319001A566B /* MessageRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3B02E51F319001A566B /* MessageRouter.swift */; };
0AE840940F21AFC07C226636 /* PrivateChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A262EDDC04B7D7B5E31F321 /* PrivateChatE2ETests.swift */; }; 0AE840940F21AFC07C226636 /* PrivateChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A262EDDC04B7D7B5E31F321 /* PrivateChatE2ETests.swift */; };
0B6F25559A21F8C69C8357C6 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */; }; 0B6F25559A21F8C69C8357C6 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */; };
0C0EFA112E6EAAAA00ABCDEF /* CTorHost.c in Sources */ = {isa = PBXBuildFile; fileRef = 0C0EFA102E6EAAAA00ABCDEF /* CTorHost.c */; };
0C0EFA122E6EAAAA00ABCDF0 /* CTorHost.c in Sources */ = {isa = PBXBuildFile; fileRef = 0C0EFA102E6EAAAA00ABCDEF /* CTorHost.c */; };
0C0EFA162E6EAABB00ABCDF4 /* TorNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0EFA132E6EAABB00ABCDF1 /* TorNotifications.swift */; };
0C0EFA172E6EAABB00ABCDF5 /* TorNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0EFA132E6EAABB00ABCDF1 /* TorNotifications.swift */; };
10E68BB889356219189E38EC /* BitchatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF625BB3AD919322C01A46B2 /* BitchatApp.swift */; }; 10E68BB889356219189E38EC /* BitchatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF625BB3AD919322C01A46B2 /* BitchatApp.swift */; };
1234567890ABCDEFFEDCBA13 /* PeerDisplayNameResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1234567890ABCDEFFEDCBA02 /* PeerDisplayNameResolver.swift */; }; 1234567890ABCDEFFEDCBA13 /* PeerDisplayNameResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1234567890ABCDEFFEDCBA02 /* PeerDisplayNameResolver.swift */; };
1234567890ABCDEFFEDCBA14 /* PeerDisplayNameResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1234567890ABCDEFFEDCBA02 /* PeerDisplayNameResolver.swift */; }; 1234567890ABCDEFFEDCBA14 /* PeerDisplayNameResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1234567890ABCDEFFEDCBA02 /* PeerDisplayNameResolver.swift */; };
@@ -166,6 +168,8 @@
EE8C3ECADAB3083A2687D50B /* NostrProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C272F137CE00FC5A96E0CC06 /* NostrProtocolTests.swift */; }; EE8C3ECADAB3083A2687D50B /* NostrProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C272F137CE00FC5A96E0CC06 /* NostrProtocolTests.swift */; };
EF49C600C1E464710DD6CA29 /* InputValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90CB7A5CD1D1A521CD31F380 /* InputValidator.swift */; }; EF49C600C1E464710DD6CA29 /* InputValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90CB7A5CD1D1A521CD31F380 /* InputValidator.swift */; };
F06732B1719EE13C5D09CE77 /* NostrProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */; }; F06732B1719EE13C5D09CE77 /* NostrProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */; };
F0A1B2C3D4E5F60718293A4B /* OSLog+Categories.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A1B2C3D4E5F60718293A4A /* OSLog+Categories.swift */; };
F0A1B2C3D4E5F60718293A4C /* OSLog+Categories.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A1B2C3D4E5F60718293A4A /* OSLog+Categories.swift */; };
F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */; }; F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */; };
FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136696FC4436A02D98CE6A77 /* KeychainManager.swift */; }; FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136696FC4436A02D98CE6A77 /* KeychainManager.swift */; };
FBC409E105493C491531B59A /* NostrProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */; }; FBC409E105493C491531B59A /* NostrProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */; };
@@ -224,12 +228,11 @@
047502B82E560F690083520F /* RelayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayController.swift; sourceTree = "<group>"; }; 047502B82E560F690083520F /* RelayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayController.swift; sourceTree = "<group>"; };
0481A3432E6D869F00FC845E /* TorManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorManager.swift; sourceTree = "<group>"; }; 0481A3432E6D869F00FC845E /* TorManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorManager.swift; sourceTree = "<group>"; };
0481A3442E6D869F00FC845E /* TorURLSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorURLSession.swift; sourceTree = "<group>"; }; 0481A3442E6D869F00FC845E /* TorURLSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorURLSession.swift; sourceTree = "<group>"; };
0C0EFA102E6EAAAA00ABCDEF /* CTorHost.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = CTorHost.c; sourceTree = "<group>"; };
0C0EFA132E6EAABB00ABCDF1 /* TorNotifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorNotifications.swift; sourceTree = "<group>"; };
0481A3532E6D877600FC845E /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; }; 0481A3532E6D877600FC845E /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
0481A3572E6D929E00FC845E /* tor-nolzma.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = "tor-nolzma.xcframework"; sourceTree = "<group>"; }; 0481A3572E6D929E00FC845E /* tor-nolzma.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = "tor-nolzma.xcframework"; sourceTree = "<group>"; };
0481A35A2E6D9BEF00FC845E /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; }; 0481A35A2E6D9BEF00FC845E /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; };
0481A35C2E6DA18600FC845E /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; }; 0481A35C2E6DA18600FC845E /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; };
0481A38F2E734CAE00FC845E /* CommandProcessorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandProcessorTests.swift; sourceTree = "<group>"; };
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransportConfig.swift; sourceTree = "<group>"; }; 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransportConfig.swift; sourceTree = "<group>"; };
048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashBookmarksStore.swift; sourceTree = "<group>"; }; 048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashBookmarksStore.swift; sourceTree = "<group>"; };
048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashBookmarksStoreTests.swift; sourceTree = "<group>"; }; 048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashBookmarksStoreTests.swift; sourceTree = "<group>"; };
@@ -247,6 +250,8 @@
049BD3B12E51F319001A566B /* NostrTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrTransport.swift; sourceTree = "<group>"; }; 049BD3B12E51F319001A566B /* NostrTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrTransport.swift; sourceTree = "<group>"; };
05BA20BC0F123F1507C5C247 /* IdentityModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityModels.swift; sourceTree = "<group>"; }; 05BA20BC0F123F1507C5C247 /* IdentityModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityModels.swift; sourceTree = "<group>"; };
0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocolTests.swift; sourceTree = "<group>"; }; 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocolTests.swift; sourceTree = "<group>"; };
0C0EFA102E6EAAAA00ABCDEF /* CTorHost.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = CTorHost.c; sourceTree = "<group>"; };
0C0EFA132E6EAABB00ABCDF1 /* TorNotifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorNotifications.swift; sourceTree = "<group>"; };
11186E29A064E8D210880E1B /* BitchatPeer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatPeer.swift; sourceTree = "<group>"; }; 11186E29A064E8D210880E1B /* BitchatPeer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatPeer.swift; sourceTree = "<group>"; };
1234567890ABCDEFFEDCBA02 /* PeerDisplayNameResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerDisplayNameResolver.swift; sourceTree = "<group>"; }; 1234567890ABCDEFFEDCBA02 /* PeerDisplayNameResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerDisplayNameResolver.swift; sourceTree = "<group>"; };
136696FC4436A02D98CE6A77 /* KeychainManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainManager.swift; sourceTree = "<group>"; }; 136696FC4436A02D98CE6A77 /* KeychainManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainManager.swift; sourceTree = "<group>"; };
@@ -298,6 +303,7 @@
EA706D8E5097785414646A8E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; EA706D8E5097785414646A8E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
EE7EFB209C86BBD956B749EC /* SecureLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureLogger.swift; sourceTree = "<group>"; }; EE7EFB209C86BBD956B749EC /* SecureLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureLogger.swift; sourceTree = "<group>"; };
EF625BB3AD919322C01A46B2 /* BitchatApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatApp.swift; sourceTree = "<group>"; }; EF625BB3AD919322C01A46B2 /* BitchatApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatApp.swift; sourceTree = "<group>"; };
F0A1B2C3D4E5F60718293A4A /* OSLog+Categories.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OSLog+Categories.swift"; sourceTree = "<group>"; };
FC75901A0F0073B5BB8356E7 /* TestConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestConstants.swift; sourceTree = "<group>"; }; FC75901A0F0073B5BB8356E7 /* TestConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestConstants.swift; sourceTree = "<group>"; };
FDC18D910D6FF2E8B1B6C885 /* SecureIdentityStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureIdentityStateManager.swift; sourceTree = "<group>"; }; FDC18D910D6FF2E8B1B6C885 /* SecureIdentityStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureIdentityStateManager.swift; sourceTree = "<group>"; };
FE7CCF2BD78A3F3DAE6DA145 /* MockBLEService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBLEService.swift; sourceTree = "<group>"; }; FE7CCF2BD78A3F3DAE6DA145 /* MockBLEService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBLEService.swift; sourceTree = "<group>"; };
@@ -471,6 +477,7 @@
32F149C43D1915831B60FE09 /* CompressionUtil.swift */, 32F149C43D1915831B60FE09 /* CompressionUtil.swift */,
90CB7A5CD1D1A521CD31F380 /* InputValidator.swift */, 90CB7A5CD1D1A521CD31F380 /* InputValidator.swift */,
EE7EFB209C86BBD956B749EC /* SecureLogger.swift */, EE7EFB209C86BBD956B749EC /* SecureLogger.swift */,
F0A1B2C3D4E5F60718293A4A /* OSLog+Categories.swift */,
); );
path = Utils; path = Utils;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -538,8 +545,9 @@
C3D98EB3E1B455E321F519F4 /* bitchatTests */ = { C3D98EB3E1B455E321F519F4 /* bitchatTests */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */,
D69A18D27F9A565FD6041E12 /* Info.plist */, D69A18D27F9A565FD6041E12 /* Info.plist */,
0481A38F2E734CAE00FC845E /* CommandProcessorTests.swift */,
048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */,
047502912E547ACC0083520F /* LocationChannelsTests.swift */, 047502912E547ACC0083520F /* LocationChannelsTests.swift */,
C272F137CE00FC5A96E0CC06 /* NostrProtocolTests.swift */, C272F137CE00FC5A96E0CC06 /* NostrProtocolTests.swift */,
980B109CBA72BC996455C62B /* BLEServiceTests.swift */, 980B109CBA72BC996455C62B /* BLEServiceTests.swift */,
@@ -802,6 +810,7 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
F0A1B2C3D4E5F60718293A4C /* OSLog+Categories.swift in Sources */,
0C0EFA122E6EAAAA00ABCDF0 /* CTorHost.c in Sources */, 0C0EFA122E6EAAAA00ABCDF0 /* CTorHost.c in Sources */,
0C0EFA172E6EAABB00ABCDF5 /* TorNotifications.swift in Sources */, 0C0EFA172E6EAABB00ABCDF5 /* TorNotifications.swift in Sources */,
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */, 048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */,
@@ -866,6 +875,7 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
F0A1B2C3D4E5F60718293A4B /* OSLog+Categories.swift in Sources */,
0C0EFA112E6EAAAA00ABCDEF /* CTorHost.c in Sources */, 0C0EFA112E6EAAAA00ABCDEF /* CTorHost.c in Sources */,
0C0EFA162E6EAABB00ABCDF4 /* TorNotifications.swift in Sources */, 0C0EFA162E6EAABB00ABCDF4 /* TorNotifications.swift in Sources */,
048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */, 048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */,
@@ -934,6 +944,7 @@
047502802E53A0FC0083520F /* FragmentationTests.swift in Sources */, 047502802E53A0FC0083520F /* FragmentationTests.swift in Sources */,
8F282E9CCA5AE1ECC001D2E4 /* IntegrationTests.swift in Sources */, 8F282E9CCA5AE1ECC001D2E4 /* IntegrationTests.swift in Sources */,
047502B12E55E8450083520F /* InputValidatorTests.swift in Sources */, 047502B12E55E8450083520F /* InputValidatorTests.swift in Sources */,
0481A3912E734CAE00FC845E /* CommandProcessorTests.swift in Sources */,
D727EA273CB214FC32612469 /* MockBluetoothMeshService.swift in Sources */, D727EA273CB214FC32612469 /* MockBluetoothMeshService.swift in Sources */,
047502932E547ACC0083520F /* LocationChannelsTests.swift in Sources */, 047502932E547ACC0083520F /* LocationChannelsTests.swift in Sources */,
048A4C2B2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */, 048A4C2B2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */,
@@ -957,6 +968,7 @@
047502812E53A0FC0083520F /* FragmentationTests.swift in Sources */, 047502812E53A0FC0083520F /* FragmentationTests.swift in Sources */,
686441ABC2AF83EE98E6ECF2 /* IntegrationTests.swift in Sources */, 686441ABC2AF83EE98E6ECF2 /* IntegrationTests.swift in Sources */,
047502B02E55E8450083520F /* InputValidatorTests.swift in Sources */, 047502B02E55E8450083520F /* InputValidatorTests.swift in Sources */,
0481A3902E734CAE00FC845E /* CommandProcessorTests.swift in Sources */,
8851F08D88C5B1DE7B9F55C6 /* MockBluetoothMeshService.swift in Sources */, 8851F08D88C5B1DE7B9F55C6 /* MockBluetoothMeshService.swift in Sources */,
047502922E547ACC0083520F /* LocationChannelsTests.swift in Sources */, 047502922E547ACC0083520F /* LocationChannelsTests.swift in Sources */,
048A4C2C2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */, 048A4C2C2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */,
@@ -1,111 +1,9 @@
{ {
"images" : [ "images" : [
{
"filename" : "icon_20x20@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "20x20"
},
{
"filename" : "icon_20x20@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "20x20"
},
{
"filename" : "icon_29x29@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "29x29"
},
{
"filename" : "icon_29x29@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "29x29"
},
{
"filename" : "icon_40x40@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "40x40"
},
{
"filename" : "icon_40x40@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "40x40"
},
{
"filename" : "icon_60x60@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "60x60"
},
{
"filename" : "icon_60x60@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "60x60"
},
{
"filename" : "icon_20x20.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "20x20"
},
{
"filename" : "icon_20x20@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "20x20"
},
{
"filename" : "icon_29x29.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "29x29"
},
{
"filename" : "icon_29x29@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "29x29"
},
{
"filename" : "icon_40x40.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "40x40"
},
{
"filename" : "icon_40x40@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "40x40"
},
{
"filename" : "icon_76x76.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "76x76"
},
{
"filename" : "icon_76x76@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "76x76"
},
{
"filename" : "icon_83.5x83.5@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "83.5x83.5"
},
{ {
"filename" : "icon_1024x1024.png", "filename" : "icon_1024x1024.png",
"idiom" : "ios-marketing", "idiom" : "universal",
"scale" : "1x", "platform" : "ios",
"size" : "1024x1024" "size" : "1024x1024"
}, },
{ {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 378 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 497 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 570 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 401 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 564 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 668 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 497 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 641 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 765 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 765 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 628 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 930 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 976 B

+3 -3
View File
@@ -163,7 +163,7 @@ struct BitchatApp: App {
} }
#if os(iOS) #if os(iOS)
class AppDelegate: NSObject, UIApplicationDelegate { final class AppDelegate: NSObject, UIApplicationDelegate {
weak var chatViewModel: ChatViewModel? weak var chatViewModel: ChatViewModel?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
@@ -175,7 +175,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
#if os(macOS) #if os(macOS)
import AppKit import AppKit
class MacAppDelegate: NSObject, NSApplicationDelegate { final class MacAppDelegate: NSObject, NSApplicationDelegate {
weak var chatViewModel: ChatViewModel? weak var chatViewModel: ChatViewModel?
func applicationWillTerminate(_ notification: Notification) { func applicationWillTerminate(_ notification: Notification) {
@@ -188,7 +188,7 @@ class MacAppDelegate: NSObject, NSApplicationDelegate {
} }
#endif #endif
class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate { final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
static let shared = NotificationDelegate() static let shared = NotificationDelegate()
weak var chatViewModel: ChatViewModel? weak var chatViewModel: ChatViewModel?
@@ -96,7 +96,7 @@ import CryptoKit
/// Singleton manager for secure identity state persistence and retrieval. /// Singleton manager for secure identity state persistence and retrieval.
/// Provides thread-safe access to identity mappings with encryption at rest. /// Provides thread-safe access to identity mappings with encryption at rest.
/// All identity data is stored encrypted in the device Keychain for security. /// All identity data is stored encrypted in the device Keychain for security.
class SecureIdentityStateManager { final class SecureIdentityStateManager {
static let shared = SecureIdentityStateManager() static let shared = SecureIdentityStateManager()
private let keychain = KeychainManager.shared private let keychain = KeychainManager.shared
@@ -129,7 +129,7 @@ class SecureIdentityStateManager {
// Try to load from keychain // Try to load from keychain
if let keyData = keychain.getIdentityKey(forKey: encryptionKeyName) { if let keyData = keychain.getIdentityKey(forKey: encryptionKeyName) {
loadedKey = SymmetricKey(data: keyData) loadedKey = SymmetricKey(data: keyData)
SecureLogger.logKeyOperation("load", keyType: "identity cache encryption key", success: true) SecureLogger.logKeyOperation(.load, keyType: "identity cache encryption key", success: true)
} }
// Generate new key if needed // Generate new key if needed
else { else {
@@ -137,7 +137,7 @@ class SecureIdentityStateManager {
let keyData = loadedKey.withUnsafeBytes { Data($0) } let keyData = loadedKey.withUnsafeBytes { Data($0) }
// Save to keychain // Save to keychain
let saved = keychain.saveIdentityKey(keyData, forKey: encryptionKeyName) let saved = keychain.saveIdentityKey(keyData, forKey: encryptionKeyName)
SecureLogger.logKeyOperation("generate", keyType: "identity cache encryption key", success: saved) SecureLogger.logKeyOperation(.generate, keyType: "identity cache encryption key", success: saved)
} }
self.encryptionKey = loadedKey self.encryptionKey = loadedKey
@@ -160,7 +160,7 @@ class SecureIdentityStateManager {
cache = try JSONDecoder().decode(IdentityCache.self, from: decryptedData) cache = try JSONDecoder().decode(IdentityCache.self, from: decryptedData)
} catch { } catch {
// Log error but continue with empty cache // Log error but continue with empty cache
SecureLogger.logError(error, context: "Failed to load identity cache", category: SecureLogger.security) SecureLogger.error(error, context: "Failed to load identity cache", category: .security)
} }
} }
@@ -191,10 +191,10 @@ class SecureIdentityStateManager {
let sealedBox = try AES.GCM.seal(data, using: encryptionKey) let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
let saved = keychain.saveIdentityKey(sealedBox.combined!, forKey: cacheKey) let saved = keychain.saveIdentityKey(sealedBox.combined!, forKey: cacheKey)
if saved { if saved {
SecureLogger.log("Identity cache saved to keychain", category: SecureLogger.security, level: .debug) SecureLogger.debug("Identity cache saved to keychain", category: .security)
} }
} catch { } catch {
SecureLogger.logError(error, context: "Failed to save identity cache", category: SecureLogger.security) SecureLogger.error(error, context: "Failed to save identity cache", category: .security)
} }
} }
@@ -395,7 +395,7 @@ class SecureIdentityStateManager {
} }
func setBlocked(_ fingerprint: String, isBlocked: Bool) { func setBlocked(_ fingerprint: String, isBlocked: Bool) {
SecureLogger.log("User \(isBlocked ? "blocked" : "unblocked"): \(fingerprint)", category: SecureLogger.security, level: .info) SecureLogger.info("User \(isBlocked ? "blocked" : "unblocked"): \(fingerprint)", category: .security)
queue.async(flags: .barrier) { queue.async(flags: .barrier) {
if var identity = self.cache.socialIdentities[fingerprint] { if var identity = self.cache.socialIdentities[fingerprint] {
@@ -519,7 +519,7 @@ class SecureIdentityStateManager {
// MARK: - Cleanup // MARK: - Cleanup
func clearAllIdentityData() { func clearAllIdentityData() {
SecureLogger.log("Clearing all identity data", category: SecureLogger.security, level: .warning) SecureLogger.warning("Clearing all identity data", category: .security)
queue.async(flags: .barrier) { queue.async(flags: .barrier) {
self.cache = IdentityCache() self.cache = IdentityCache()
@@ -529,7 +529,7 @@ class SecureIdentityStateManager {
// Delete from keychain // Delete from keychain
let deleted = self.keychain.deleteIdentityKey(forKey: self.cacheKey) let deleted = self.keychain.deleteIdentityKey(forKey: self.cacheKey)
SecureLogger.logKeyOperation("delete", keyType: "identity cache", success: deleted) SecureLogger.logKeyOperation(.delete, keyType: "identity cache", success: deleted)
} }
} }
@@ -543,7 +543,7 @@ class SecureIdentityStateManager {
// MARK: - Verification // MARK: - Verification
func setVerified(fingerprint: String, verified: Bool) { func setVerified(fingerprint: String, verified: Bool) {
SecureLogger.log("Fingerprint \(verified ? "verified" : "unverified"): \(fingerprint)", category: SecureLogger.security, level: .info) SecureLogger.info("Fingerprint \(verified ? "verified" : "unverified"): \(fingerprint)", category: .security)
queue.async(flags: .barrier) { queue.async(flags: .barrier) {
if verified { if verified {
+2 -2
View File
@@ -35,10 +35,10 @@
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string> <string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
<key>NSBluetoothPeripheralUsageDescription</key> <key>NSBluetoothPeripheralUsageDescription</key>
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string> <string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>bitchat uses the camera to scan QR codes to verify peers.</string> <string>bitchat uses the camera to scan QR codes to verify peers.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>bluetooth-central</string> <string>bluetooth-central</string>
+18 -30
View File
@@ -9,7 +9,7 @@
import Foundation import Foundation
/// Coordinates Noise handshakes to prevent race conditions and ensure reliable encryption establishment /// Coordinates Noise handshakes to prevent race conditions and ensure reliable encryption establishment
class NoiseHandshakeCoordinator { final class NoiseHandshakeCoordinator {
// MARK: - Handshake State // MARK: - Handshake State
@@ -68,8 +68,7 @@ class NoiseHandshakeCoordinator {
switch state { switch state {
case .initiating(_, let lastAttempt): case .initiating(_, let lastAttempt):
if Date().timeIntervalSince(lastAttempt) > handshakeTimeout { if Date().timeIntervalSince(lastAttempt) > handshakeTimeout {
SecureLogger.log("Forcing new handshake with \(remotePeerID) - previous stuck in initiating", SecureLogger.warning("Forcing new handshake with \(remotePeerID) - previous stuck in initiating", category: .handshake)
category: SecureLogger.handshake, level: .warning)
return true return true
} }
default: default:
@@ -77,8 +76,7 @@ class NoiseHandshakeCoordinator {
} }
} }
SecureLogger.log("Already in active handshake with \(remotePeerID), state: \(state)", SecureLogger.debug("Already in active handshake with \(remotePeerID), state: \(state)", category: .handshake)
category: SecureLogger.handshake, level: .debug)
return false return false
} }
@@ -107,8 +105,7 @@ class NoiseHandshakeCoordinator {
handshakeQueue.async(flags: .barrier) { handshakeQueue.async(flags: .barrier) {
let attempt = self.getCurrentAttempt(for: peerID) + 1 let attempt = self.getCurrentAttempt(for: peerID) + 1
self.handshakeStates[peerID] = .initiating(attempt: attempt, lastAttempt: Date()) self.handshakeStates[peerID] = .initiating(attempt: attempt, lastAttempt: Date())
SecureLogger.log("Recording handshake initiation with \(peerID), attempt \(attempt)", SecureLogger.info("Recording handshake initiation with \(peerID), attempt \(attempt)", category: .handshake)
category: SecureLogger.handshake, level: .info)
} }
} }
@@ -116,8 +113,7 @@ class NoiseHandshakeCoordinator {
func recordHandshakeResponse(peerID: String) { func recordHandshakeResponse(peerID: String) {
handshakeQueue.async(flags: .barrier) { handshakeQueue.async(flags: .barrier) {
self.handshakeStates[peerID] = .responding(since: Date()) self.handshakeStates[peerID] = .responding(since: Date())
SecureLogger.log("Recording handshake response to \(peerID)", SecureLogger.info("Recording handshake response to \(peerID)", category: .handshake)
category: SecureLogger.handshake, level: .info)
} }
} }
@@ -125,8 +121,7 @@ class NoiseHandshakeCoordinator {
func recordHandshakeSuccess(peerID: String) { func recordHandshakeSuccess(peerID: String) {
handshakeQueue.async(flags: .barrier) { handshakeQueue.async(flags: .barrier) {
self.handshakeStates[peerID] = .established(since: Date()) self.handshakeStates[peerID] = .established(since: Date())
SecureLogger.log("Handshake successfully established with \(peerID)", SecureLogger.info("Handshake successfully established with \(peerID)", category: .handshake)
category: SecureLogger.handshake, level: .info)
} }
} }
@@ -136,8 +131,7 @@ class NoiseHandshakeCoordinator {
let attempts = self.getCurrentAttempt(for: peerID) let attempts = self.getCurrentAttempt(for: peerID)
let canRetry = attempts < self.maxHandshakeAttempts let canRetry = attempts < self.maxHandshakeAttempts
self.handshakeStates[peerID] = .failed(reason: reason, canRetry: canRetry, lastAttempt: Date()) self.handshakeStates[peerID] = .failed(reason: reason, canRetry: canRetry, lastAttempt: Date())
SecureLogger.log("Handshake failed with \(peerID): \(reason), canRetry: \(canRetry)", SecureLogger.warning("Handshake failed with \(peerID): \(reason), canRetry: \(canRetry)", category: .handshake)
category: SecureLogger.handshake, level: .warning)
} }
} }
@@ -146,8 +140,7 @@ class NoiseHandshakeCoordinator {
return handshakeQueue.sync { return handshakeQueue.sync {
// If we're already established, reject new handshakes // If we're already established, reject new handshakes
if case .established = handshakeStates[remotePeerID] { if case .established = handshakeStates[remotePeerID] {
SecureLogger.log("Rejecting handshake from \(remotePeerID) - already established", SecureLogger.debug("Rejecting handshake from \(remotePeerID) - already established", category: .handshake)
category: SecureLogger.handshake, level: .debug)
return false return false
} }
@@ -157,8 +150,7 @@ class NoiseHandshakeCoordinator {
if role == .initiator { if role == .initiator {
if case .initiating = handshakeStates[remotePeerID] { if case .initiating = handshakeStates[remotePeerID] {
// They shouldn't be initiating, but accept it to recover from race condition // They shouldn't be initiating, but accept it to recover from race condition
SecureLogger.log("Accepting handshake from \(remotePeerID) despite being initiator (race condition recovery)", SecureLogger.warning("Accepting handshake from \(remotePeerID) despite being initiator (race condition recovery)", category: .handshake)
category: SecureLogger.handshake, level: .warning)
return true return true
} }
} }
@@ -215,8 +207,7 @@ class NoiseHandshakeCoordinator {
func resetHandshakeState(for peerID: String) { func resetHandshakeState(for peerID: String) {
handshakeQueue.async(flags: .barrier) { handshakeQueue.async(flags: .barrier) {
self.handshakeStates.removeValue(forKey: peerID) self.handshakeStates.removeValue(forKey: peerID)
SecureLogger.log("Reset handshake state for \(peerID)", SecureLogger.debug("Reset handshake state for \(peerID)", category: .handshake)
category: SecureLogger.handshake, level: .debug)
} }
} }
@@ -256,8 +247,7 @@ class NoiseHandshakeCoordinator {
if isStale { if isStale {
stalePeerIDs.append(peerID) stalePeerIDs.append(peerID)
SecureLogger.log("Found stale handshake state for \(peerID): \(state)", SecureLogger.warning("Found stale handshake state for \(peerID): \(state)", category: .handshake)
category: SecureLogger.handshake, level: .warning)
} }
} }
@@ -270,8 +260,7 @@ class NoiseHandshakeCoordinator {
for i in 0..<sessionsToRemove { for i in 0..<sessionsToRemove {
let peerID = sortedSessions[i].peerID let peerID = sortedSessions[i].peerID
stalePeerIDs.append(peerID) stalePeerIDs.append(peerID)
SecureLogger.log("Removing old established session for \(peerID) to maintain session limit", SecureLogger.info("Removing old established session for \(peerID) to maintain session limit", category: .handshake)
category: SecureLogger.handshake, level: .info)
} }
} }
@@ -281,8 +270,7 @@ class NoiseHandshakeCoordinator {
} }
if !stalePeerIDs.isEmpty { if !stalePeerIDs.isEmpty {
SecureLogger.log("Cleaned up \(stalePeerIDs.count) stale handshake states", SecureLogger.info("Cleaned up \(stalePeerIDs.count) stale handshake states", category: .handshake)
category: SecureLogger.handshake, level: .info)
} }
return stalePeerIDs return stalePeerIDs
@@ -333,7 +321,7 @@ class NoiseHandshakeCoordinator {
/// Log current handshake states for debugging /// Log current handshake states for debugging
func logHandshakeStates() { func logHandshakeStates() {
handshakeQueue.sync { handshakeQueue.sync {
SecureLogger.log("=== Handshake States ===", category: SecureLogger.handshake, level: .debug) SecureLogger.debug("=== Handshake States ===", category: .handshake)
for (peerID, state) in handshakeStates { for (peerID, state) in handshakeStates {
let stateDesc: String let stateDesc: String
switch state { switch state {
@@ -352,18 +340,18 @@ class NoiseHandshakeCoordinator {
case .failed(let reason, let canRetry, let lastAttempt): case .failed(let reason, let canRetry, let lastAttempt):
stateDesc = "failed: \(reason) (canRetry: \(canRetry), last: \(lastAttempt))" stateDesc = "failed: \(reason) (canRetry: \(canRetry), last: \(lastAttempt))"
} }
SecureLogger.log(" \(peerID): \(stateDesc)", category: SecureLogger.handshake, level: .debug) SecureLogger.debug(" \(peerID): \(stateDesc)", category: .handshake)
} }
SecureLogger.log("========================", category: SecureLogger.handshake, level: .debug) SecureLogger.debug("========================", category: .handshake)
} }
} }
/// Clear all handshake states - used during panic mode /// Clear all handshake states - used during panic mode
func clearAllHandshakeStates() { func clearAllHandshakeStates() {
handshakeQueue.async(flags: .barrier) { handshakeQueue.async(flags: .barrier) {
SecureLogger.log("Clearing all handshake states for panic mode", category: SecureLogger.handshake, level: .warning) SecureLogger.warning("Clearing all handshake states for panic mode", category: .handshake)
self.handshakeStates.removeAll() self.handshakeStates.removeAll()
self.processedHandshakeMessages.removeAll() self.processedHandshakeMessages.removeAll()
} }
} }
} }
+13 -14
View File
@@ -79,7 +79,6 @@
import Foundation import Foundation
import CryptoKit import CryptoKit
import os.log
// Core Noise Protocol implementation // Core Noise Protocol implementation
// Based on the Noise Protocol Framework specification // Based on the Noise Protocol Framework specification
@@ -127,7 +126,7 @@ struct NoiseProtocolName {
/// Handles ChaCha20-Poly1305 AEAD encryption with automatic nonce management /// Handles ChaCha20-Poly1305 AEAD encryption with automatic nonce management
/// and replay protection using a sliding window algorithm. /// and replay protection using a sliding window algorithm.
/// - Warning: Nonce reuse would be catastrophic for security /// - Warning: Nonce reuse would be catastrophic for security
class NoiseCipherState { final class NoiseCipherState {
// Constants for replay protection // Constants for replay protection
private static let NONCE_SIZE_BYTES = 4 private static let NONCE_SIZE_BYTES = 4
private static let REPLAY_WINDOW_SIZE = 1024 private static let REPLAY_WINDOW_SIZE = 1024
@@ -285,7 +284,7 @@ class NoiseCipherState {
// Log high nonce values that might indicate issues // Log high nonce values that might indicate issues
if currentNonce > Self.HIGH_NONCE_WARNING_THRESHOLD { if currentNonce > Self.HIGH_NONCE_WARNING_THRESHOLD {
SecureLogger.log("High nonce value detected: \(currentNonce) - consider rekeying", category: SecureLogger.encryption, level: .warning) SecureLogger.warning("High nonce value detected: \(currentNonce) - consider rekeying", category: .encryption)
} }
return combinedPayload return combinedPayload
@@ -307,13 +306,13 @@ class NoiseCipherState {
if useExtractedNonce { if useExtractedNonce {
// Extract nonce and ciphertext from combined payload // Extract nonce and ciphertext from combined payload
guard let (extractedNonce, actualCiphertext) = try extractNonceFromCiphertextPayload(ciphertext) else { guard let (extractedNonce, actualCiphertext) = try extractNonceFromCiphertextPayload(ciphertext) else {
SecureLogger.log("Decrypt failed: Could not extract nonce from payload") SecureLogger.debug("Decrypt failed: Could not extract nonce from payload")
throw NoiseError.invalidCiphertext throw NoiseError.invalidCiphertext
} }
// Validate nonce with sliding window replay protection // Validate nonce with sliding window replay protection
guard isValidNonce(extractedNonce) else { guard isValidNonce(extractedNonce) else {
SecureLogger.log("Replay attack detected: nonce \(extractedNonce) rejected") SecureLogger.debug("Replay attack detected: nonce \(extractedNonce) rejected")
throw NoiseError.replayDetected throw NoiseError.replayDetected
} }
@@ -342,7 +341,7 @@ class NoiseCipherState {
// Log high nonce values that might indicate issues // Log high nonce values that might indicate issues
if decryptionNonce > Self.HIGH_NONCE_WARNING_THRESHOLD { if decryptionNonce > Self.HIGH_NONCE_WARNING_THRESHOLD {
SecureLogger.log("High nonce value detected: \(decryptionNonce) - consider rekeying", category: SecureLogger.encryption, level: .warning) SecureLogger.warning("High nonce value detected: \(decryptionNonce) - consider rekeying", category: .encryption)
} }
do { do {
@@ -355,9 +354,9 @@ class NoiseCipherState {
nonce += 1 nonce += 1
return plaintext return plaintext
} catch { } catch {
SecureLogger.log("Decrypt failed: \(error) for nonce \(decryptionNonce)") SecureLogger.debug("Decrypt failed: \(error) for nonce \(decryptionNonce)")
// Log authentication failures with nonce info // Log authentication failures with nonce info
SecureLogger.log("Decryption failed at nonce \(decryptionNonce)", category: SecureLogger.encryption, level: .error) SecureLogger.error("Decryption failed at nonce \(decryptionNonce)", category: .encryption)
throw error throw error
} }
} }
@@ -384,7 +383,7 @@ class NoiseCipherState {
/// Responsible for key derivation, protocol name hashing, and maintaining /// Responsible for key derivation, protocol name hashing, and maintaining
/// the chaining key that provides key separation between handshake messages. /// the chaining key that provides key separation between handshake messages.
/// - Note: This class implements the SymmetricState object from the Noise spec /// - Note: This class implements the SymmetricState object from the Noise spec
class NoiseSymmetricState { final class NoiseSymmetricState {
private var cipherState: NoiseCipherState private var cipherState: NoiseCipherState
private var chainingKey: Data private var chainingKey: Data
private var hash: Data private var hash: Data
@@ -488,7 +487,7 @@ class NoiseSymmetricState {
/// This is the main interface for establishing encrypted sessions between peers. /// This is the main interface for establishing encrypted sessions between peers.
/// Manages the handshake state machine, message patterns, and key derivation. /// Manages the handshake state machine, message patterns, and key derivation.
/// - Important: Each handshake instance should only be used once /// - Important: Each handshake instance should only be used once
class NoiseHandshakeState { final class NoiseHandshakeState {
private let role: NoiseRole private let role: NoiseRole
private let pattern: NoisePattern private let pattern: NoisePattern
private var symmetricState: NoiseSymmetricState private var symmetricState: NoiseSymmetricState
@@ -661,7 +660,7 @@ class NoiseHandshakeState {
do { do {
remoteEphemeralPublic = try NoiseHandshakeState.validatePublicKey(ephemeralData) remoteEphemeralPublic = try NoiseHandshakeState.validatePublicKey(ephemeralData)
} catch { } catch {
SecureLogger.log("Invalid ephemeral public key received", category: SecureLogger.security, level: .warning) SecureLogger.warning("Invalid ephemeral public key received", category: .security)
throw NoiseError.invalidMessage throw NoiseError.invalidMessage
} }
symmetricState.mixHash(ephemeralData) symmetricState.mixHash(ephemeralData)
@@ -678,7 +677,7 @@ class NoiseHandshakeState {
let decrypted = try symmetricState.decryptAndHash(staticData) let decrypted = try symmetricState.decryptAndHash(staticData)
remoteStaticPublic = try NoiseHandshakeState.validatePublicKey(decrypted) remoteStaticPublic = try NoiseHandshakeState.validatePublicKey(decrypted)
} catch { } catch {
SecureLogger.logSecurityEvent(.authenticationFailed(peerID: "Unknown - handshake"), level: .error) SecureLogger.error(.authenticationFailed(peerID: "Unknown - handshake"))
throw NoiseError.authenticationFailure throw NoiseError.authenticationFailure
} }
@@ -877,7 +876,7 @@ extension NoiseHandshakeState {
// Check against known bad points // Check against known bad points
if lowOrderPoints.contains(keyData) { if lowOrderPoints.contains(keyData) {
SecureLogger.log("Low-order point detected", category: SecureLogger.security, level: .warning) SecureLogger.warning("Low-order point detected", category: .security)
throw NoiseError.invalidPublicKey throw NoiseError.invalidPublicKey
} }
@@ -887,7 +886,7 @@ extension NoiseHandshakeState {
return publicKey return publicKey
} catch { } catch {
// If CryptoKit rejects it, it's invalid // If CryptoKit rejects it, it's invalid
SecureLogger.log("CryptoKit validation failed", category: SecureLogger.security, level: .warning) SecureLogger.warning("CryptoKit validation failed", category: .security)
throw NoiseError.invalidPublicKey throw NoiseError.invalidPublicKey
} }
} }
@@ -61,7 +61,7 @@ struct NoiseSecurityValidator {
// MARK: - Enhanced Noise Session with Security // MARK: - Enhanced Noise Session with Security
class SecureNoiseSession: NoiseSession { final class SecureNoiseSession: NoiseSession {
private(set) var messageCount: UInt64 = 0 private(set) var messageCount: UInt64 = 0
private let sessionStartTime = Date() private let sessionStartTime = Date()
private(set) var lastActivityTime = Date() private(set) var lastActivityTime = Date()
@@ -135,7 +135,7 @@ class SecureNoiseSession: NoiseSession {
// MARK: - Rate Limiter // MARK: - Rate Limiter
class NoiseRateLimiter { final class NoiseRateLimiter {
private var handshakeTimestamps: [String: [Date]] = [:] // peerID -> timestamps private var handshakeTimestamps: [String: [Date]] = [:] // peerID -> timestamps
private var messageTimestamps: [String: [Date]] = [:] // peerID -> timestamps private var messageTimestamps: [String: [Date]] = [:] // peerID -> timestamps
@@ -153,7 +153,7 @@ class NoiseRateLimiter {
// Check global rate limit first // Check global rate limit first
globalHandshakeTimestamps = globalHandshakeTimestamps.filter { $0 > oneMinuteAgo } globalHandshakeTimestamps = globalHandshakeTimestamps.filter { $0 > oneMinuteAgo }
if globalHandshakeTimestamps.count >= NoiseSecurityConstants.maxGlobalHandshakesPerMinute { if globalHandshakeTimestamps.count >= NoiseSecurityConstants.maxGlobalHandshakesPerMinute {
SecureLogger.log("Global handshake rate limit exceeded: \(globalHandshakeTimestamps.count)/\(NoiseSecurityConstants.maxGlobalHandshakesPerMinute) per minute", category: SecureLogger.security, level: .warning) SecureLogger.warning("Global handshake rate limit exceeded: \(globalHandshakeTimestamps.count)/\(NoiseSecurityConstants.maxGlobalHandshakesPerMinute) per minute", category: .security)
return false return false
} }
@@ -162,7 +162,7 @@ class NoiseRateLimiter {
timestamps = timestamps.filter { $0 > oneMinuteAgo } timestamps = timestamps.filter { $0 > oneMinuteAgo }
if timestamps.count >= NoiseSecurityConstants.maxHandshakesPerMinute { if timestamps.count >= NoiseSecurityConstants.maxHandshakesPerMinute {
SecureLogger.log("Per-peer handshake rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxHandshakesPerMinute) per minute", category: SecureLogger.security, level: .warning) SecureLogger.warning("Per-peer handshake rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxHandshakesPerMinute) per minute", category: .security)
return false return false
} }
@@ -182,7 +182,7 @@ class NoiseRateLimiter {
// Check global rate limit first // Check global rate limit first
globalMessageTimestamps = globalMessageTimestamps.filter { $0 > oneSecondAgo } globalMessageTimestamps = globalMessageTimestamps.filter { $0 > oneSecondAgo }
if globalMessageTimestamps.count >= NoiseSecurityConstants.maxGlobalMessagesPerSecond { if globalMessageTimestamps.count >= NoiseSecurityConstants.maxGlobalMessagesPerSecond {
SecureLogger.log("Global message rate limit exceeded: \(globalMessageTimestamps.count)/\(NoiseSecurityConstants.maxGlobalMessagesPerSecond) per second", category: SecureLogger.security, level: .warning) SecureLogger.warning("Global message rate limit exceeded: \(globalMessageTimestamps.count)/\(NoiseSecurityConstants.maxGlobalMessagesPerSecond) per second", category: .security)
return false return false
} }
@@ -191,7 +191,7 @@ class NoiseRateLimiter {
timestamps = timestamps.filter { $0 > oneSecondAgo } timestamps = timestamps.filter { $0 > oneSecondAgo }
if timestamps.count >= NoiseSecurityConstants.maxMessagesPerSecond { if timestamps.count >= NoiseSecurityConstants.maxMessagesPerSecond {
SecureLogger.log("Per-peer message rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxMessagesPerSecond) per second", category: SecureLogger.security, level: .warning) SecureLogger.warning("Per-peer message rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxMessagesPerSecond) per second", category: .security)
return false return false
} }
+14 -16
View File
@@ -8,7 +8,6 @@
import Foundation import Foundation
import CryptoKit import CryptoKit
import os.log
// MARK: - Noise Session State // MARK: - Noise Session State
@@ -92,7 +91,7 @@ class NoiseSession {
func processHandshakeMessage(_ message: Data) throws -> Data? { func processHandshakeMessage(_ message: Data) throws -> Data? {
return try sessionQueue.sync(flags: .barrier) { return try sessionQueue.sync(flags: .barrier) {
SecureLogger.log("NoiseSession[\(peerID)]: Processing handshake message, current state: \(state), role: \(role)", category: SecureLogger.noise, level: .debug) SecureLogger.debug("NoiseSession[\(peerID)]: Processing handshake message, current state: \(state), role: \(role)")
// Initialize handshake state if needed (for responders) // Initialize handshake state if needed (for responders)
if state == .uninitialized && role == .responder { if state == .uninitialized && role == .responder {
@@ -103,7 +102,7 @@ class NoiseSession {
remoteStaticKey: nil remoteStaticKey: nil
) )
state = .handshaking state = .handshaking
SecureLogger.log("NoiseSession[\(peerID)]: Initialized handshake state for responder", category: SecureLogger.noise, level: .debug) SecureLogger.debug("NoiseSession[\(peerID)]: Initialized handshake state for responder")
} }
guard case .handshaking = state, let handshake = handshakeState else { guard case .handshaking = state, let handshake = handshakeState else {
@@ -112,7 +111,7 @@ class NoiseSession {
// Process incoming message // Process incoming message
_ = try handshake.readMessage(message) _ = try handshake.readMessage(message)
SecureLogger.log("NoiseSession[\(peerID)]: Read handshake message, checking if complete", category: SecureLogger.noise, level: .debug) SecureLogger.debug("NoiseSession[\(peerID)]: Read handshake message, checking if complete")
// Check if handshake is complete // Check if handshake is complete
if handshake.isHandshakeComplete() { if handshake.isHandshakeComplete() {
@@ -130,15 +129,15 @@ class NoiseSession {
state = .established state = .established
handshakeState = nil // Clear handshake state handshakeState = nil // Clear handshake state
SecureLogger.log("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established", category: SecureLogger.noise, level: .debug) SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established")
SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID)) SecureLogger.info(.handshakeCompleted(peerID: peerID))
return nil return nil
} else { } else {
// Generate response // Generate response
let response = try handshake.writeMessage() let response = try handshake.writeMessage()
sentHandshakeMessages.append(response) sentHandshakeMessages.append(response)
SecureLogger.log("NoiseSession[\(peerID)]: Generated handshake response of size \(response.count)", category: SecureLogger.noise, level: .debug) SecureLogger.debug("NoiseSession[\(peerID)]: Generated handshake response of size \(response.count)")
// Check if handshake is complete after writing // Check if handshake is complete after writing
if handshake.isHandshakeComplete() { if handshake.isHandshakeComplete() {
@@ -156,8 +155,8 @@ class NoiseSession {
state = .established state = .established
handshakeState = nil // Clear handshake state handshakeState = nil // Clear handshake state
SecureLogger.log("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established", category: SecureLogger.noise, level: .debug) SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established")
SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID)) SecureLogger.info(.handshakeCompleted(peerID: peerID))
} }
return response return response
@@ -242,7 +241,7 @@ class NoiseSession {
handshakeHash = nil handshakeHash = nil
if wasEstablished { if wasEstablished {
SecureLogger.logSecurityEvent(.sessionExpired(peerID: peerID)) SecureLogger.info(.sessionExpired(peerID: peerID))
} }
} }
} }
@@ -250,7 +249,7 @@ class NoiseSession {
// MARK: - Session Manager // MARK: - Session Manager
class NoiseSessionManager { final class NoiseSessionManager {
private var sessions: [String: NoiseSession] = [:] private var sessions: [String: NoiseSession] = [:]
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent) private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
@@ -287,7 +286,7 @@ class NoiseSessionManager {
managerQueue.sync(flags: .barrier) { managerQueue.sync(flags: .barrier) {
if let session = sessions[peerID] { if let session = sessions[peerID] {
if session.isEstablished() { if session.isEstablished() {
SecureLogger.logSecurityEvent(.sessionExpired(peerID: peerID)) SecureLogger.info(.sessionExpired(peerID: peerID))
} }
// Clear sensitive data before removing // Clear sensitive data before removing
session.reset() session.reset()
@@ -331,7 +330,7 @@ class NoiseSessionManager {
} catch { } catch {
// Clean up failed session // Clean up failed session
_ = sessions.removeValue(forKey: peerID) _ = sessions.removeValue(forKey: peerID)
SecureLogger.logSecurityEvent(.handshakeFailed(peerID: peerID, error: error.localizedDescription), level: .error) SecureLogger.error(.handshakeFailed(peerID: peerID, error: error.localizedDescription))
throw error throw error
} }
} }
@@ -348,8 +347,7 @@ class NoiseSessionManager {
// for a good reason (e.g., decryption failure, restart, etc.) // for a good reason (e.g., decryption failure, restart, etc.)
// We should accept the new handshake to re-establish encryption // We should accept the new handshake to re-establish encryption
if existing.isEstablished() { if existing.isEstablished() {
SecureLogger.log("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session", SecureLogger.info("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session", category: .session)
category: SecureLogger.session, level: .info)
_ = sessions.removeValue(forKey: peerID) _ = sessions.removeValue(forKey: peerID)
shouldCreateNew = true shouldCreateNew = true
} else { } else {
@@ -404,7 +402,7 @@ class NoiseSessionManager {
self?.onSessionFailed?(peerID, error) self?.onSessionFailed?(peerID, error)
} }
SecureLogger.logSecurityEvent(.handshakeFailed(peerID: peerID, error: error.localizedDescription), level: .error) SecureLogger.error(.handshakeFailed(peerID: peerID, error: error.localizedDescription))
throw error throw error
} }
} }
+5 -5
View File
@@ -54,7 +54,7 @@ final class GeoRelayDirectory {
Task.detached { Task.detached {
let ready = await TorManager.shared.awaitReady() let ready = await TorManager.shared.awaitReady()
if !ready { if !ready {
SecureLogger.log("GeoRelayDirectory: Tor not ready; skipping remote fetch (fail-closed)", category: SecureLogger.session, level: .warning) SecureLogger.warning("GeoRelayDirectory: Tor not ready; skipping remote fetch (fail-closed)", category: .session)
return return
} }
let task = TorURLSession.shared.session.dataTask(with: req) { [weak self] data, _, error in let task = TorURLSession.shared.session.dataTask(with: req) { [weak self] data, _, error in
@@ -66,12 +66,12 @@ final class GeoRelayDirectory {
self.entries = parsed self.entries = parsed
self.persistCache(text) self.persistCache(text)
UserDefaults.standard.set(Date(), forKey: self.lastFetchKey) UserDefaults.standard.set(Date(), forKey: self.lastFetchKey)
SecureLogger.log("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: SecureLogger.session, level: .info) SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
} }
return return
} }
} }
SecureLogger.log("GeoRelayDirectory: remote fetch failed; keeping local entries", category: SecureLogger.session, level: .warning) SecureLogger.warning("GeoRelayDirectory: remote fetch failed; keeping local entries", category: .session)
} }
task.resume() task.resume()
} }
@@ -82,7 +82,7 @@ final class GeoRelayDirectory {
do { do {
try text.data(using: .utf8)?.write(to: url, options: .atomic) try text.data(using: .utf8)?.write(to: url, options: .atomic)
} catch { } catch {
SecureLogger.log("GeoRelayDirectory: failed to write cache: \(error)", category: SecureLogger.session, level: .warning) SecureLogger.warning("GeoRelayDirectory: failed to write cache: \(error)", category: .session)
} }
} }
@@ -113,7 +113,7 @@ final class GeoRelayDirectory {
let text = String(data: data, encoding: .utf8) { let text = String(data: data, encoding: .utf8) {
return Self.parseCSV(text) return Self.parseCSV(text)
} }
SecureLogger.log("GeoRelayDirectory: no local CSV found; entries empty", category: SecureLogger.session, level: .warning) SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session)
return [] return []
} }
+3 -5
View File
@@ -78,8 +78,7 @@ struct NostrProtocol {
) )
// Successfully unwrapped gift wrap // Successfully unwrapped gift wrap
} catch { } catch {
SecureLogger.log("❌ Failed to unwrap gift wrap: \(error)", SecureLogger.error("❌ Failed to unwrap gift wrap: \(error)", category: .session)
category: SecureLogger.session, level: .error)
throw error throw error
} }
@@ -92,8 +91,7 @@ struct NostrProtocol {
) )
// Successfully opened seal // Successfully opened seal
} catch { } catch {
SecureLogger.log("❌ Failed to open seal: \(error)", SecureLogger.error("❌ Failed to open seal: \(error)", category: .session)
category: SecureLogger.session, level: .error)
throw error throw error
} }
@@ -109,7 +107,7 @@ struct NostrProtocol {
teleported: Bool = false teleported: Bool = false
) throws -> NostrEvent { ) throws -> NostrEvent {
var tags = [["g", geohash]] var tags = [["g", geohash]]
if let nickname = nickname, !nickname.isEmpty { if let nickname = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty {
tags.append(["n", nickname]) tags.append(["n", nickname])
} }
if teleported { if teleported {
+83 -87
View File
@@ -4,7 +4,7 @@ import Combine
/// Manages WebSocket connections to Nostr relays /// Manages WebSocket connections to Nostr relays
@MainActor @MainActor
class NostrRelayManager: ObservableObject { final class NostrRelayManager: ObservableObject {
static let shared = NostrRelayManager() static let shared = NostrRelayManager()
// Track gift-wraps (kind 1059) we initiated so we can log OK acks at info // Track gift-wraps (kind 1059) we initiated so we can log OK acks at info
private(set) static var pendingGiftWrapIDs = Set<String>() private(set) static var pendingGiftWrapIDs = Set<String>()
@@ -82,10 +82,10 @@ class NostrRelayManager: ObservableObject {
let ready = await TorManager.shared.awaitReady() let ready = await TorManager.shared.awaitReady()
await MainActor.run { await MainActor.run {
if !ready { if !ready {
SecureLogger.log("❌ Tor not ready; aborting relay connections (fail-closed)", category: SecureLogger.session, level: .error) SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
return return
} }
SecureLogger.log("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: SecureLogger.session, level: .debug) SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session)
for relay in self.relays { for relay in self.relays {
self.connectToRelay(relay.url) self.connectToRelay(relay.url)
} }
@@ -231,12 +231,11 @@ class NostrRelayManager: ObservableObject {
do { do {
let message = try encoder.encode(req) let message = try encoder.encode(req)
guard let messageString = String(data: message, encoding: .utf8) else { guard let messageString = String(data: message, encoding: .utf8) else {
SecureLogger.log("❌ Failed to encode subscription request", category: SecureLogger.session, level: .error) SecureLogger.error("❌ Failed to encode subscription request", category: .session)
return return
} }
// SecureLogger.log("📋 Subscription filter JSON: \(messageString.prefix(200))...", // SecureLogger.debug("📋 Subscription filter JSON: \(messageString.prefix(200))...", category: .session)
// category: SecureLogger.session, level: .debug)
// Target specific relays if provided; else default. Filter permanently failed relays. // Target specific relays if provided; else default. Filter permanently failed relays.
let baseUrls = relayUrls ?? Self.defaultRelays let baseUrls = relayUrls ?? Self.defaultRelays
@@ -251,8 +250,9 @@ class NostrRelayManager: ObservableObject {
map[id] = messageString map[id] = messageString
self.pendingSubscriptions[url] = map self.pendingSubscriptions[url] = map
} }
SecureLogger.log("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session)
category: SecureLogger.session, level: .debug) // Ensure we actually have sockets opening to these relays so queued REQs can flush
ensureConnections(to: urls)
// If some targets are already connected, flush immediately for them // If some targets are already connected, flush immediately for them
for url in urls { for url in urls {
if let r = relays.first(where: { $0.url == url }), r.isConnected { if let r = relays.first(where: { $0.url == url }), r.isConnected {
@@ -260,8 +260,7 @@ class NostrRelayManager: ObservableObject {
} }
} }
} catch { } catch {
SecureLogger.log("❌ Failed to encode subscription request: \(error)", SecureLogger.error("❌ Failed to encode subscription request: \(error)", category: .session)
category: SecureLogger.session, level: .error)
} }
} }
@@ -293,7 +292,7 @@ class NostrRelayManager: ObservableObject {
private func connectToRelay(_ urlString: String) { private func connectToRelay(_ urlString: String) {
guard let url = URL(string: urlString) else { guard let url = URL(string: urlString) else {
SecureLogger.log("Invalid relay URL: \(urlString)", category: SecureLogger.session, level: .warning) SecureLogger.warning("Invalid relay URL: \(urlString)", category: .session)
return return
} }
@@ -319,7 +318,7 @@ class NostrRelayManager: ObservableObject {
let ready = await TorManager.shared.awaitReady() let ready = await TorManager.shared.awaitReady()
await MainActor.run { await MainActor.run {
if ready { self.connectToRelay(urlString) } if ready { self.connectToRelay(urlString) }
else { SecureLogger.log("❌ Tor not ready; skipping connection to \(urlString)", category: SecureLogger.session, level: .error) } else { SecureLogger.error("❌ Tor not ready; skipping connection to \(urlString)", category: .session) }
} }
} }
return return
@@ -338,14 +337,12 @@ class NostrRelayManager: ObservableObject {
task.sendPing { [weak self] error in task.sendPing { [weak self] error in
DispatchQueue.main.async { DispatchQueue.main.async {
if error == nil { if error == nil {
SecureLogger.log("✅ Connected to Nostr relay: \(urlString)", SecureLogger.debug("✅ Connected to Nostr relay: \(urlString)", category: .session)
category: SecureLogger.session, level: .debug)
self?.updateRelayStatus(urlString, isConnected: true) self?.updateRelayStatus(urlString, isConnected: true)
// Flush any pending subscriptions for this relay // Flush any pending subscriptions for this relay
self?.flushPendingSubscriptions(for: urlString) self?.flushPendingSubscriptions(for: urlString)
} else { } else {
SecureLogger.log("❌ Failed to connect to Nostr relay \(urlString): \(error?.localizedDescription ?? "Unknown error")", SecureLogger.error("❌ Failed to connect to Nostr relay \(urlString): \(error?.localizedDescription ?? "Unknown error")", category: .session)
category: SecureLogger.session, level: .error)
self?.updateRelayStatus(urlString, isConnected: false, error: error) self?.updateRelayStatus(urlString, isConnected: false, error: error)
// Trigger disconnection handler for proper backoff // Trigger disconnection handler for proper backoff
self?.handleDisconnection(relayUrl: urlString, error: error ?? NSError(domain: "NostrRelay", code: -1, userInfo: nil)) self?.handleDisconnection(relayUrl: urlString, error: error ?? NSError(domain: "NostrRelay", code: -1, userInfo: nil))
@@ -362,8 +359,7 @@ class NostrRelayManager: ObservableObject {
if self.subscriptions[relayUrl]?.contains(id) == true { continue } if self.subscriptions[relayUrl]?.contains(id) == true { continue }
connection.send(.string(messageString)) { error in connection.send(.string(messageString)) { error in
if let error = error { if let error = error {
SecureLogger.log("❌ Failed to send pending subscription to \(relayUrl): \(error)", SecureLogger.error("❌ Failed to send pending subscription to \(relayUrl): \(error)", category: .session)
category: SecureLogger.session, level: .error)
} else { } else {
Task { @MainActor in Task { @MainActor in
var subs = self.subscriptions[relayUrl] ?? Set<String>() var subs = self.subscriptions[relayUrl] ?? Set<String>()
@@ -382,26 +378,12 @@ class NostrRelayManager: ObservableObject {
switch result { switch result {
case .success(let message): case .success(let message):
switch message { // Parse off-main to reduce UI jank, then hop back for state updates
case .string(let text): Task.detached(priority: .utility) {
// Parse off-main to reduce UI jank, then hop back for state updates guard let parsed = ParsedInbound(message) else { return }
Task.detached(priority: .utility) { await MainActor.run {
guard let parsed = parseInboundMessage(text) else { return } NostrRelayManager.shared.handleParsedMessage(parsed, from: relayUrl)
await MainActor.run {
NostrRelayManager.shared.handleParsedMessage(parsed, from: relayUrl)
}
} }
case .data(let data):
if let text = String(data: data, encoding: .utf8) {
Task.detached(priority: .utility) {
guard let parsed = parseInboundMessage(text) else { return }
await MainActor.run {
NostrRelayManager.shared.handleParsedMessage(parsed, from: relayUrl)
}
}
}
@unknown default:
break
} }
// Continue receiving // Continue receiving
@@ -426,8 +408,7 @@ class NostrRelayManager: ObservableObject {
switch parsed { switch parsed {
case .event(let subId, let event): case .event(let subId, let event):
if event.kind != 1059 { if event.kind != 1059 {
SecureLogger.log("📥 Event kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", SecureLogger.debug("📥 Event kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
category: SecureLogger.session, level: .debug)
} }
if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) { if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) {
self.relays[index].messagesReceived += 1 self.relays[index].messagesReceived += 1
@@ -435,8 +416,7 @@ class NostrRelayManager: ObservableObject {
if let handler = self.messageHandlers[subId] { if let handler = self.messageHandlers[subId] {
handler(event) handler(event)
} else { } else {
SecureLogger.log("⚠️ No handler for subscription \(subId)", SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session)
category: SecureLogger.session, level: .warning)
} }
case .eose: case .eose:
// No-op for now // No-op for now
@@ -444,12 +424,14 @@ class NostrRelayManager: ObservableObject {
case .ok(let eventId, let success, let reason): case .ok(let eventId, let success, let reason):
if success { if success {
_ = Self.pendingGiftWrapIDs.remove(eventId) _ = Self.pendingGiftWrapIDs.remove(eventId)
SecureLogger.log("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)", SecureLogger.debug("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)", category: .session)
category: SecureLogger.session, level: .debug)
} else { } else {
let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil
SecureLogger.log("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)", if isGiftWrap {
category: SecureLogger.session, level: isGiftWrap ? .warning : .error) SecureLogger.warning("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)", category: .session)
} else {
SecureLogger.error("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)", category: .session)
}
} }
case .notice: case .notice:
break break
@@ -463,17 +445,14 @@ class NostrRelayManager: ObservableObject {
let data = try encoder.encode(req) let data = try encoder.encode(req)
let message = String(data: data, encoding: .utf8) ?? "" let message = String(data: data, encoding: .utf8) ?? ""
SecureLogger.log("📤 Send kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", SecureLogger.debug("📤 Send kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
category: SecureLogger.session, level: .debug)
connection.send(.string(message)) { [weak self] error in connection.send(.string(message)) { [weak self] error in
DispatchQueue.main.async { DispatchQueue.main.async {
if let error = error { if let error = error {
SecureLogger.log("❌ Failed to send event to \(relayUrl): \(error)", SecureLogger.error("❌ Failed to send event to \(relayUrl): \(error)", category: .session)
category: SecureLogger.session, level: .error)
} else { } else {
// SecureLogger.log(" Event sent to relay: \(relayUrl)", // SecureLogger.debug(" Event sent to relay: \(relayUrl)", category: .session)
// category: SecureLogger.session, level: .debug)
// Update relay stats // Update relay stats
if let index = self?.relays.firstIndex(where: { $0.url == relayUrl }) { if let index = self?.relays.firstIndex(where: { $0.url == relayUrl }) {
self?.relays[index].messagesSent += 1 self?.relays[index].messagesSent += 1
@@ -482,7 +461,7 @@ class NostrRelayManager: ObservableObject {
} }
} }
} catch { } catch {
SecureLogger.log("Failed to encode event: \(error)", category: SecureLogger.session, level: .error) SecureLogger.error("Failed to encode event: \(error)", category: .session)
} }
} }
@@ -521,7 +500,7 @@ class NostrRelayManager: ObservableObject {
errorDescription.contains("dns") || errorDescription.contains("dns") ||
(ns.domain == NSURLErrorDomain && ns.code == NSURLErrorBadServerResponse) { (ns.domain == NSURLErrorDomain && ns.code == NSURLErrorBadServerResponse) {
if relays.first(where: { $0.url == relayUrl })?.lastError == nil { if relays.first(where: { $0.url == relayUrl })?.lastError == nil {
SecureLogger.log("Nostr relay permanent failure for \(relayUrl) - not retrying (code=\(ns.code))", category: SecureLogger.session, level: .warning) SecureLogger.warning("Nostr relay permanent failure for \(relayUrl) - not retrying (code=\(ns.code))", category: .session)
} }
if let index = relays.firstIndex(where: { $0.url == relayUrl }) { if let index = relays.firstIndex(where: { $0.url == relayUrl }) {
relays[index].lastError = error relays[index].lastError = error
@@ -539,8 +518,7 @@ class NostrRelayManager: ObservableObject {
// Stop attempting after max attempts // Stop attempting after max attempts
if relays[index].reconnectAttempts >= maxReconnectAttempts { if relays[index].reconnectAttempts >= maxReconnectAttempts {
SecureLogger.log("Max reconnection attempts (\(maxReconnectAttempts)) reached for \(relayUrl)", SecureLogger.warning("Max reconnection attempts (\(maxReconnectAttempts)) reached for \(relayUrl)", category: .session)
category: SecureLogger.session, level: .warning)
return return
} }
@@ -634,42 +612,60 @@ private enum ParsedInbound {
case ok(eventId: String, success: Bool, reason: String) case ok(eventId: String, success: Bool, reason: String)
case eose(subscriptionId: String) case eose(subscriptionId: String)
case notice(String) case notice(String)
init?(_ message: URLSessionWebSocketTask.Message) {
guard let data = message.data,
let array = try? JSONSerialization.jsonObject(with: data) as? [Any],
array.count >= 2,
let type = array[0] as? String else {
return nil
}
switch type {
case "EVENT":
if array.count >= 3,
let subId = array[1] as? String,
let eventDict = array[2] as? [String: Any],
let event = try? NostrEvent(from: eventDict) {
self = .event(subId: subId, event: event)
return
}
return nil
case "EOSE":
if let subId = array[1] as? String {
self = .eose(subscriptionId: subId)
return
}
return nil
case "OK":
if array.count >= 3,
let eventId = array[1] as? String,
let success = array[2] as? Bool {
let reason = array.count >= 4 ? (array[3] as? String ?? "no reason given") : "no reason given"
self = .ok(eventId: eventId, success: success, reason: reason)
return
}
return nil
case "NOTICE":
if array.count >= 2, let msg = array[1] as? String {
self = .notice(msg)
return
}
return nil
default:
return nil
}
}
} }
// Off-main JSON parse to avoid UI jank; pure function, not actor-isolated private extension URLSessionWebSocketTask.Message {
private func parseInboundMessage(_ message: String) -> ParsedInbound? { var data: Data? {
guard let data = message.data(using: .utf8) else { return nil } switch self {
do { case .string(let text): text.data(using: .utf8)
if let array = try JSONSerialization.jsonObject(with: data) as? [Any], case .data(let data): data
array.count >= 2, @unknown default: nil
let type = array[0] as? String {
switch type {
case "EVENT":
if array.count >= 3,
let subId = array[1] as? String,
let eventDict = array[2] as? [String: Any] {
let event = try NostrEvent(from: eventDict)
return .event(subId: subId, event: event)
}
case "EOSE":
if let subId = array[1] as? String { return .eose(subscriptionId: subId) }
case "OK":
if array.count >= 3,
let eventId = array[1] as? String,
let success = array[2] as? Bool {
let reason = array.count >= 4 ? (array[3] as? String ?? "no reason given") : "no reason given"
return .ok(eventId: eventId, success: success, reason: reason)
}
case "NOTICE":
if array.count >= 2, let msg = array[1] as? String { return .notice(msg) }
default:
return nil
}
} }
} catch {
// Ignore
} }
return nil
} }
// MARK: - Nostr Protocol Types // MARK: - Nostr Protocol Types
+1 -1
View File
@@ -397,7 +397,7 @@ enum DeliveryStatus: Codable, Equatable {
/// Handles both broadcast messages and private encrypted messages, /// Handles both broadcast messages and private encrypted messages,
/// with support for mentions, replies, and delivery tracking. /// with support for mentions, replies, and delivery tracking.
/// - Note: This is the primary data model for chat messages /// - Note: This is the primary data model for chat messages
class BitchatMessage: Codable { final class BitchatMessage: Codable {
let id: String let id: String
let sender: String let sender: String
let content: String let content: String
+1 -1
View File
@@ -9,7 +9,7 @@
import Foundation import Foundation
/// Manages autocomplete functionality for chat /// Manages autocomplete functionality for chat
class AutocompleteService { final class AutocompleteService {
private let mentionRegex = try? NSRegularExpression(pattern: "@([\\p{L}0-9_]*)$", options: []) private let mentionRegex = try? NSRegularExpression(pattern: "@([\\p{L}0-9_]*)$", options: [])
private let commandRegex = try? NSRegularExpression(pattern: "^/([a-z]*)$", options: []) private let commandRegex = try? NSRegularExpression(pattern: "^/([a-z]*)$", options: [])
+94 -121
View File
@@ -230,8 +230,7 @@ final class BLEService: NSObject {
let newSize = data.count let newSize = data.count
// If single chunk exceeds cap, drop it immediately // If single chunk exceeds cap, drop it immediately
if newSize > capBytes { if newSize > capBytes {
SecureLogger.log("⚠️ Dropping oversized write chunk (\(newSize)B) for peripheral \(uuid)", SecureLogger.warning("⚠️ Dropping oversized write chunk (\(newSize)B) for peripheral \(uuid)", category: .session)
category: SecureLogger.session, level: .warning)
} else { } else {
// Append and trim from the front to respect cap // Append and trim from the front to respect cap
var total = queue.reduce(0) { $0 + $1.count } var total = queue.reduce(0) { $0 + $1.count }
@@ -244,8 +243,7 @@ final class BLEService: NSObject {
removedBytes += removed.count removedBytes += removed.count
total -= removed.count total -= removed.count
} }
SecureLogger.log("📉 Trimmed pending write buffer for \(uuid) by \(removedBytes)B to \(total)B", SecureLogger.warning("📉 Trimmed pending write buffer for \(uuid) by \(removedBytes)B to \(total)B", category: .session)
category: SecureLogger.session, level: .warning)
} }
self.pendingPeripheralWrites[uuid] = queue.isEmpty ? nil : queue self.pendingPeripheralWrites[uuid] = queue.isEmpty ? nil : queue
} }
@@ -341,8 +339,7 @@ final class BLEService: NSObject {
// Set up Noise session establishment callback // Set up Noise session establishment callback
// This ensures we send pending messages only when session is truly established // This ensures we send pending messages only when session is truly established
noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in
SecureLogger.log("🔐 Noise session authenticated with \(peerID), fingerprint: \(fingerprint.prefix(16))...", SecureLogger.debug("🔐 Noise session authenticated with \(peerID), fingerprint: \(fingerprint.prefix(16))...")
category: SecureLogger.noise, level: .debug)
// Send any messages that were queued during handshake // Send any messages that were queued during handshake
self?.messageQueue.async { [weak self] in self?.messageQueue.async { [weak self] in
self?.sendPendingMessagesAfterHandshake(for: peerID) self?.sendPendingMessagesAfterHandshake(for: peerID)
@@ -587,8 +584,7 @@ final class BLEService: NSObject {
} }
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) { func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
SecureLogger.log("🔔 sendFavoriteNotification called - peerID: \(peerID), isFavorite: \(isFavorite)", SecureLogger.debug("🔔 sendFavoriteNotification called - peerID: \(peerID), isFavorite: \(isFavorite)", category: .session)
category: SecureLogger.session, level: .debug)
// Include Nostr public key in the notification // Include Nostr public key in the notification
var content = isFavorite ? "[FAVORITED]" : "[UNFAVORITED]" var content = isFavorite ? "[FAVORITED]" : "[UNFAVORITED]"
@@ -596,12 +592,10 @@ final class BLEService: NSObject {
// Add our Nostr public key if available // Add our Nostr public key if available
if let myNostrIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() { if let myNostrIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() {
content += ":" + myNostrIdentity.npub content += ":" + myNostrIdentity.npub
SecureLogger.log("📝 Sending favorite notification with Nostr npub: \(myNostrIdentity.npub)", SecureLogger.debug("📝 Sending favorite notification with Nostr npub: \(myNostrIdentity.npub)", category: .session)
category: SecureLogger.session, level: .debug)
} }
SecureLogger.log("📤 Sending favorite notification to \(peerID): \(content)", SecureLogger.debug("📤 Sending favorite notification to \(peerID): \(content)", category: .session)
category: SecureLogger.session, level: .debug)
sendPrivateMessage(content, to: peerID, messageID: UUID().uuidString) sendPrivateMessage(content, to: peerID, messageID: UUID().uuidString)
} }
@@ -611,8 +605,7 @@ final class BLEService: NSObject {
payload.append(contentsOf: receipt.originalMessageID.utf8) payload.append(contentsOf: receipt.originalMessageID.utf8)
if noiseService.hasEstablishedSession(with: peerID) { if noiseService.hasEstablishedSession(with: peerID) {
SecureLogger.log("📤 Sending READ receipt for message \(receipt.originalMessageID) to \(peerID)", SecureLogger.debug("📤 Sending READ receipt for message \(receipt.originalMessageID) to \(peerID)", category: .session)
category: SecureLogger.session, level: .debug)
do { do {
let encrypted = try noiseService.encrypt(payload, for: peerID) let encrypted = try noiseService.encrypt(payload, for: peerID)
let packet = BitchatPacket( let packet = BitchatPacket(
@@ -630,7 +623,7 @@ final class BLEService: NSObject {
messageQueue.async { [weak self] in self?.broadcastPacket(packet) } messageQueue.async { [weak self] in self?.broadcastPacket(packet) }
} }
} catch { } catch {
SecureLogger.log("Failed to send read receipt: \(error)", category: SecureLogger.noise, level: .error) SecureLogger.error("Failed to send read receipt: \(error)")
} }
} else { } else {
// Queue for after handshake and initiate if needed // Queue for after handshake and initiate if needed
@@ -639,8 +632,7 @@ final class BLEService: NSObject {
self.pendingNoisePayloadsAfterHandshake[peerID, default: []].append(payload) self.pendingNoisePayloadsAfterHandshake[peerID, default: []].append(payload)
} }
if !noiseService.hasSession(with: peerID) { initiateNoiseHandshake(with: peerID) } if !noiseService.hasSession(with: peerID) { initiateNoiseHandshake(with: peerID) }
SecureLogger.log("🕒 Queued READ receipt for \(peerID) until handshake completes", SecureLogger.debug("🕒 Queued READ receipt for \(peerID) until handshake completes", category: .session)
category: SecureLogger.session, level: .debug)
} }
} }
@@ -682,7 +674,7 @@ final class BLEService: NSObject {
messageQueue.async { [weak self] in self?.broadcastPacket(packet) } messageQueue.async { [weak self] in self?.broadcastPacket(packet) }
} }
} catch { } catch {
SecureLogger.log("Failed to send verification payload: \(error)", category: SecureLogger.noise, level: .error) SecureLogger.error("Failed to send verification payload: \(error)")
} }
} }
@@ -748,7 +740,7 @@ final class BLEService: NSObject {
guard let self = self else { return } guard let self = self else { return }
guard content.count <= self.maxMessageLength else { guard content.count <= self.maxMessageLength else {
SecureLogger.log("Message too long: \(content.count) chars", category: SecureLogger.session, level: .error) SecureLogger.error("Message too long: \(content.count) chars", category: .session)
return return
} }
@@ -771,7 +763,7 @@ final class BLEService: NSObject {
ttl: self.messageTTL ttl: self.messageTTL
) )
guard let signedPacket = self.noiseService.signPacket(basePacket) else { guard let signedPacket = self.noiseService.signPacket(basePacket) else {
SecureLogger.log("❌ Failed to sign public message", category: SecureLogger.security, level: .error) SecureLogger.error("❌ Failed to sign public message", category: .security)
return return
} }
// Pre-mark our own broadcast as processed to avoid handling relayed self copy // Pre-mark our own broadcast as processed to avoid handling relayed self copy
@@ -789,7 +781,7 @@ final class BLEService: NSObject {
// MARK: - Private Message Handling // MARK: - Private Message Handling
private func sendPrivateMessage(_ content: String, to recipientID: String, messageID: String) { private func sendPrivateMessage(_ content: String, to recipientID: String, messageID: String) {
SecureLogger.log("📨 Sending PM to \(recipientID): \(content.prefix(30))...", category: SecureLogger.session, level: .debug) SecureLogger.debug("📨 Sending PM to \(recipientID): \(content.prefix(30))...", category: .session)
// Check if we have an established Noise session // Check if we have an established Noise session
if noiseService.hasEstablishedSession(with: recipientID) { if noiseService.hasEstablishedSession(with: recipientID) {
@@ -798,7 +790,7 @@ final class BLEService: NSObject {
// Create TLV-encoded private message // Create TLV-encoded private message
let privateMessage = PrivateMessagePacket(messageID: messageID, content: content) let privateMessage = PrivateMessagePacket(messageID: messageID, content: content)
guard let tlvData = privateMessage.encode() else { guard let tlvData = privateMessage.encode() else {
SecureLogger.log("Failed to encode private message with TLV", category: SecureLogger.noise, level: .error) SecureLogger.error("Failed to encode private message with TLV")
return return
} }
@@ -847,11 +839,11 @@ final class BLEService: NSObject {
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent) self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent)
} }
} catch { } catch {
SecureLogger.log("Failed to encrypt message: \(error)", category: SecureLogger.noise, level: .error) SecureLogger.error("Failed to encrypt message: \(error)")
} }
} else { } else {
// Queue message for sending after handshake completes // Queue message for sending after handshake completes
SecureLogger.log("🤝 No session with \(recipientID), initiating handshake and queueing message", category: SecureLogger.session, level: .debug) SecureLogger.debug("🤝 No session with \(recipientID), initiating handshake and queueing message", category: .session)
// Queue the message (especially important for favorite notifications) // Queue the message (especially important for favorite notifications)
collectionsQueue.sync(flags: .barrier) { collectionsQueue.sync(flags: .barrier) {
@@ -896,7 +888,7 @@ final class BLEService: NSObject {
} }
} }
} catch { } catch {
SecureLogger.log("Failed to initiate handshake: \(error)", category: SecureLogger.noise, level: .error) SecureLogger.error("Failed to initiate handshake: \(error)")
} }
} }
@@ -910,8 +902,7 @@ final class BLEService: NSObject {
guard let messages = pendingMessages, !messages.isEmpty else { return } guard let messages = pendingMessages, !messages.isEmpty else { return }
SecureLogger.log("📤 Sending \(messages.count) pending messages after handshake to \(peerID)", SecureLogger.debug("📤 Sending \(messages.count) pending messages after handshake to \(peerID)", category: .session)
category: SecureLogger.session, level: .debug)
// Send each pending message directly (we know session is established) // Send each pending message directly (we know session is established)
for (content, messageID) in messages { for (content, messageID) in messages {
@@ -919,7 +910,7 @@ final class BLEService: NSObject {
// Use the same TLV format as normal sends to keep receiver decoding consistent // Use the same TLV format as normal sends to keep receiver decoding consistent
let privateMessage = PrivateMessagePacket(messageID: messageID, content: content) let privateMessage = PrivateMessagePacket(messageID: messageID, content: content)
guard let tlvData = privateMessage.encode() else { guard let tlvData = privateMessage.encode() else {
SecureLogger.log("Failed to encode pending private message TLV", category: SecureLogger.noise, level: .error) SecureLogger.error("Failed to encode pending private message TLV")
continue continue
} }
@@ -946,11 +937,9 @@ final class BLEService: NSObject {
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent) self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent)
} }
SecureLogger.log("✅ Sent pending message \(messageID) to \(peerID) after handshake", SecureLogger.debug("✅ Sent pending message \(messageID) to \(peerID) after handshake", category: .session)
category: SecureLogger.session, level: .debug)
} catch { } catch {
SecureLogger.log("Failed to send pending message after handshake: \(error)", SecureLogger.error("Failed to send pending message after handshake: \(error)")
category: SecureLogger.noise, level: .error)
// Notify delegate of failure // Notify delegate of failure
notifyUI { [weak self] in notifyUI { [weak self] in
@@ -966,7 +955,7 @@ final class BLEService: NSObject {
// Encode once using a small per-type padding policy, then delegate by type // Encode once using a small per-type padding policy, then delegate by type
let padForBLE = padPolicy(for: packet.type) let padForBLE = padPolicy(for: packet.type)
guard let data = packet.toBinaryData(padding: padForBLE) else { guard let data = packet.toBinaryData(padding: padForBLE) else {
SecureLogger.log("❌ Failed to convert packet to binary data", category: SecureLogger.session, level: .error) SecureLogger.error("❌ Failed to convert packet to binary data", category: .session)
return return
} }
if packet.type == MessageType.noiseEncrypted.rawValue { if packet.type == MessageType.noiseEncrypted.rawValue {
@@ -1037,7 +1026,7 @@ final class BLEService: NSObject {
guard let self = self else { return } guard let self = self else { return }
if self.pendingNotifications.count < TransportConfig.blePendingNotificationsCapCount { if self.pendingNotifications.count < TransportConfig.blePendingNotificationsCapCount {
self.pendingNotifications.append((data: data, centrals: [central])) self.pendingNotifications.append((data: data, centrals: [central]))
SecureLogger.log("📋 Queued encrypted packet for retry (notification queue full)", category: SecureLogger.session, level: .debug) SecureLogger.debug("📋 Queued encrypted packet for retry (notification queue full)", category: .session)
} }
} }
} }
@@ -1150,7 +1139,7 @@ final class BLEService: NSObject {
if byMsg[msgID] == nil { if byMsg[msgID] == nil {
byMsg[msgID] = (packet: packet, enqueuedAt: Date()) byMsg[msgID] = (packet: packet, enqueuedAt: Date())
self.pendingDirectedRelays[recipientPeerID] = byMsg self.pendingDirectedRelays[recipientPeerID] = byMsg
SecureLogger.log("🧳 Spooling directed packet for \(recipientPeerID) mid=\(msgID.prefix(8))", category: SecureLogger.session, level: .debug) SecureLogger.debug("🧳 Spooling directed packet for \(recipientPeerID) mid=\(msgID.prefix(8))", category: .session)
} }
} }
} }
@@ -1322,7 +1311,7 @@ final class BLEService: NSObject {
if let originalPacket = BinaryProtocol.decode(reassembled) { if let originalPacket = BinaryProtocol.decode(reassembled) {
handleReceivedPacket(originalPacket, from: peerID) handleReceivedPacket(originalPacket, from: peerID)
} else { } else {
SecureLogger.log("❌ Failed to decode reassembled packet (type=\(originalType), total=\(total))", category: SecureLogger.session, level: .error) SecureLogger.error("❌ Failed to decode reassembled packet (type=\(originalType), total=\(total))", category: .session)
} }
// Cleanup // Cleanup
@@ -1342,8 +1331,7 @@ final class BLEService: NSObject {
// Only log non-announce packets to reduce noise // Only log non-announce packets to reduce noise
if packet.type != MessageType.announce.rawValue { if packet.type != MessageType.announce.rawValue {
// Log packet details for debugging // Log packet details for debugging
SecureLogger.log("📦 Handling packet type \(packet.type) from \(senderID), messageID: \(messageID)", SecureLogger.debug("📦 Handling packet type \(packet.type) from \(senderID), messageID: \(messageID)", category: .session)
category: SecureLogger.session, level: .debug)
} }
// Efficient deduplication // Efficient deduplication
@@ -1352,8 +1340,7 @@ final class BLEService: NSObject {
// Announce packets (type 1) are sent every 10 seconds for peer discovery // Announce packets (type 1) are sent every 10 seconds for peer discovery
// It's normal to see these as duplicates - don't log them to reduce noise // It's normal to see these as duplicates - don't log them to reduce noise
if packet.type != MessageType.announce.rawValue { if packet.type != MessageType.announce.rawValue {
SecureLogger.log("⚠️ Duplicate packet ignored: \(messageID)", SecureLogger.debug("⚠️ Duplicate packet ignored: \(messageID)", category: .session)
category: SecureLogger.session, level: .debug)
} }
// In sparse graphs (<=2 neighbors), keep the pending relay to ensure bridging. // In sparse graphs (<=2 neighbors), keep the pending relay to ensure bridging.
// In denser graphs, cancel the pending relay to reduce redundant floods. // In denser graphs, cancel the pending relay to reduce redundant floods.
@@ -1406,7 +1393,7 @@ final class BLEService: NSObject {
handleLeave(packet, from: senderID) handleLeave(packet, from: senderID)
default: default:
SecureLogger.log("⚠️ Unknown message type: \(packet.type)", category: SecureLogger.session, level: .warning) SecureLogger.warning("⚠️ Unknown message type: \(packet.type)", category: .session)
break break
} }
@@ -1446,7 +1433,7 @@ final class BLEService: NSObject {
private func handleAnnounce(_ packet: BitchatPacket, from peerID: String) { private func handleAnnounce(_ packet: BitchatPacket, from peerID: String) {
guard let announcement = AnnouncementPacket.decode(from: packet.payload) else { guard let announcement = AnnouncementPacket.decode(from: packet.payload) else {
SecureLogger.log("❌ Failed to decode announce packet from \(peerID)", category: SecureLogger.session, level: .error) SecureLogger.error("❌ Failed to decode announce packet from \(peerID)", category: .session)
return return
} }
@@ -1454,7 +1441,7 @@ final class BLEService: NSObject {
// This helps detect relayed or spoofed announces. Only warn in release; assert in debug. // This helps detect relayed or spoofed announces. Only warn in release; assert in debug.
let derivedFromKey = PeerIDUtils.derivePeerID(fromPublicKey: announcement.noisePublicKey) let derivedFromKey = PeerIDUtils.derivePeerID(fromPublicKey: announcement.noisePublicKey)
if derivedFromKey != peerID { if derivedFromKey != peerID {
SecureLogger.log("⚠️ Announce sender mismatch: derived \(derivedFromKey.prefix(8))… vs packet \(peerID.prefix(8))", category: SecureLogger.security, level: .warning) SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.prefix(8))… vs packet \(peerID.prefix(8))", category: .security)
} }
@@ -1471,11 +1458,11 @@ final class BLEService: NSObject {
if packet.signature != nil { if packet.signature != nil {
verifiedAnnounce = noiseService.verifyPacketSignature(packet, publicKey: announcement.signingPublicKey) verifiedAnnounce = noiseService.verifyPacketSignature(packet, publicKey: announcement.signingPublicKey)
if !verifiedAnnounce { if !verifiedAnnounce {
SecureLogger.log("⚠️ Signature verification for announce failed \(peerID.prefix(8))", category: SecureLogger.security, level: .warning) SecureLogger.warning("⚠️ Signature verification for announce failed \(peerID.prefix(8))", category: .security)
} }
} }
if let existingKey = existingPeerForVerify?.noisePublicKey, existingKey != announcement.noisePublicKey { if let existingKey = existingPeerForVerify?.noisePublicKey, existingKey != announcement.noisePublicKey {
SecureLogger.log("⚠️ Announce key mismatch for \(peerID.prefix(8))… — keeping unverified", category: SecureLogger.security, level: .warning) SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.prefix(8))… — keeping unverified", category: .security)
verifiedAnnounce = false verifiedAnnounce = false
} }
@@ -1508,7 +1495,7 @@ final class BLEService: NSObject {
// Require verified announce; ignore otherwise (no backward compatibility) // Require verified announce; ignore otherwise (no backward compatibility)
if !verified { if !verified {
SecureLogger.log("❌ Ignoring unverified announce from \(peerID.prefix(8))", category: SecureLogger.security, level: .warning) SecureLogger.warning("❌ Ignoring unverified announce from \(peerID.prefix(8))", category: .security)
return return
} }
@@ -1541,17 +1528,17 @@ final class BLEService: NSObject {
if isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription { if isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription {
let now = Date() let now = Date()
if existingPeer == nil { if existingPeer == nil {
SecureLogger.log("🆕 New peer: \(announcement.nickname)", category: SecureLogger.session, level: .debug) SecureLogger.debug("🆕 New peer: \(announcement.nickname)", category: .session)
} else if wasDisconnected { } else if wasDisconnected {
// Debounce 'reconnected' logs within short window // Debounce 'reconnected' logs within short window
if let last = lastReconnectLogAt[peerID], now.timeIntervalSince(last) < TransportConfig.bleReconnectLogDebounceSeconds { if let last = lastReconnectLogAt[peerID], now.timeIntervalSince(last) < TransportConfig.bleReconnectLogDebounceSeconds {
// Skip duplicate log // Skip duplicate log
} else { } else {
SecureLogger.log("🔄 Peer \(announcement.nickname) reconnected", category: SecureLogger.session, level: .debug) SecureLogger.debug("🔄 Peer \(announcement.nickname) reconnected", category: .session)
lastReconnectLogAt[peerID] = now lastReconnectLogAt[peerID] = now
} }
} else if existingPeer?.nickname != announcement.nickname { } else if existingPeer?.nickname != announcement.nickname {
SecureLogger.log("🔄 Peer \(peerID) changed nickname: \(existingPeer?.nickname ?? "Unknown") -> \(announcement.nickname)", category: SecureLogger.session, level: .debug) SecureLogger.debug("🔄 Peer \(peerID) changed nickname: \(existingPeer?.nickname ?? "Unknown") -> \(announcement.nickname)", category: .session)
} }
} }
} }
@@ -1661,12 +1648,12 @@ final class BLEService: NSObject {
} }
guard accepted else { guard accepted else {
SecureLogger.log("🚫 Dropping public message from unverified or unknown peer \(peerID.prefix(8))", category: SecureLogger.security, level: .warning) SecureLogger.warning("🚫 Dropping public message from unverified or unknown peer \(peerID.prefix(8))", category: .security)
return return
} }
guard let content = String(data: packet.payload, encoding: .utf8) else { guard let content = String(data: packet.payload, encoding: .utf8) else {
SecureLogger.log("❌ Failed to decode message payload as UTF-8", category: SecureLogger.session, level: .error) SecureLogger.error("❌ Failed to decode message payload as UTF-8", category: .session)
return return
} }
// Determine if we have a direct link to the sender // Determine if we have a direct link to the sender
@@ -1678,7 +1665,7 @@ final class BLEService: NSObject {
} }
let pathTag = hasDirectLink ? "direct" : "mesh" let pathTag = hasDirectLink ? "direct" : "mesh"
SecureLogger.log("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)): \(String(content.prefix(50)))\(content.count > 50 ? "..." : "")", category: SecureLogger.session, level: .debug) SecureLogger.debug("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)): \(String(content.prefix(50)))\(content.count > 50 ? "..." : "")", category: .session)
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
notifyUI { [weak self] in notifyUI { [weak self] in
@@ -1710,7 +1697,7 @@ final class BLEService: NSObject {
// Session establishment will trigger onPeerAuthenticated callback // Session establishment will trigger onPeerAuthenticated callback
// which will send any pending messages at the right time // which will send any pending messages at the right time
} catch { } catch {
SecureLogger.log("Failed to process handshake: \(error)", category: SecureLogger.noise, level: .error) SecureLogger.error("Failed to process handshake: \(error)")
// Try initiating a new handshake // Try initiating a new handshake
if !noiseService.hasSession(with: peerID) { if !noiseService.hasSession(with: peerID) {
initiateNoiseHandshake(with: peerID) initiateNoiseHandshake(with: peerID)
@@ -1720,17 +1707,16 @@ final class BLEService: NSObject {
} }
private func handleNoiseEncrypted(_ packet: BitchatPacket, from peerID: String) { private func handleNoiseEncrypted(_ packet: BitchatPacket, from peerID: String) {
SecureLogger.log("🔐 handleNoiseEncrypted called for packet from \(peerID)", SecureLogger.debug("🔐 handleNoiseEncrypted called for packet from \(peerID)")
category: SecureLogger.noise, level: .debug)
guard let recipientID = packet.recipientID else { guard let recipientID = packet.recipientID else {
SecureLogger.log("⚠️ Encrypted message has no recipient ID", category: SecureLogger.session, level: .warning) SecureLogger.warning("⚠️ Encrypted message has no recipient ID", category: .session)
return return
} }
let recipientHex = recipientID.hexEncodedString() let recipientHex = recipientID.hexEncodedString()
if recipientHex != myPeerID { if recipientHex != myPeerID {
SecureLogger.log("🔐 Encrypted message not for me (for \(recipientHex), I am \(myPeerID))", category: SecureLogger.session, level: .debug) SecureLogger.debug("🔐 Encrypted message not for me (for \(recipientHex), I am \(myPeerID))", category: .session)
return return
} }
@@ -1772,19 +1758,17 @@ final class BLEService: NSObject {
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyResponse, payload: Data(payloadData), timestamp: ts) self?.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyResponse, payload: Data(payloadData), timestamp: ts)
} }
default: default:
SecureLogger.log("⚠️ Unknown noise payload type: \(payloadType)", category: SecureLogger.noise, level: .warning) SecureLogger.warning("⚠️ Unknown noise payload type: \(payloadType)")
} }
} catch NoiseEncryptionError.sessionNotEstablished { } catch NoiseEncryptionError.sessionNotEstablished {
// We received an encrypted message before establishing a session with this peer. // We received an encrypted message before establishing a session with this peer.
// Trigger a handshake so future messages can be decrypted. // Trigger a handshake so future messages can be decrypted.
SecureLogger.log("🔑 Encrypted message from \(peerID) without session; initiating handshake", SecureLogger.debug("🔑 Encrypted message from \(peerID) without session; initiating handshake")
category: SecureLogger.noise, level: .debug)
if !noiseService.hasSession(with: peerID) { if !noiseService.hasSession(with: peerID) {
initiateNoiseHandshake(with: peerID) initiateNoiseHandshake(with: peerID)
} }
} catch { } catch {
SecureLogger.log("❌ Failed to decrypt message from \(peerID): \(error)", SecureLogger.error("❌ Failed to decrypt message from \(peerID): \(error)")
category: SecureLogger.noise, level: .error)
} }
} }
@@ -1808,7 +1792,7 @@ final class BLEService: NSObject {
// MARK: - Helper Functions // MARK: - Helper Functions
private func sendLeave() { private func sendLeave() {
SecureLogger.log("👋 Sending leave announcement", category: SecureLogger.session, level: .debug) SecureLogger.debug("👋 Sending leave announcement", category: .session)
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.leave.rawValue, type: MessageType.leave.rawValue,
ttl: messageTTL, ttl: messageTTL,
@@ -1845,7 +1829,7 @@ final class BLEService: NSObject {
) )
guard let payload = announcement.encode() else { guard let payload = announcement.encode() else {
SecureLogger.log("❌ Failed to encode announce packet", category: SecureLogger.session, level: .error) SecureLogger.error("❌ Failed to encode announce packet", category: .session)
return return
} }
@@ -1862,7 +1846,7 @@ final class BLEService: NSObject {
// Sign the packet using the noise private key // Sign the packet using the noise private key
guard let signedPacket = noiseService.signPacket(packet) else { guard let signedPacket = noiseService.signPacket(packet) else {
SecureLogger.log("❌ Failed to sign announce packet", category: SecureLogger.security, level: .error) SecureLogger.error("❌ Failed to sign announce packet", category: .security)
return return
} }
@@ -1895,7 +1879,7 @@ final class BLEService: NSObject {
) )
broadcastPacket(packet) broadcastPacket(packet)
} catch { } catch {
SecureLogger.log("Failed to send delivery ACK: \(error)", category: SecureLogger.noise, level: .error) SecureLogger.error("Failed to send delivery ACK: \(error)")
} }
} else { } else {
// Queue for after handshake and initiate if needed // Queue for after handshake and initiate if needed
@@ -1904,8 +1888,7 @@ final class BLEService: NSObject {
self.pendingNoisePayloadsAfterHandshake[peerID, default: []].append(payload) self.pendingNoisePayloadsAfterHandshake[peerID, default: []].append(payload)
} }
if !noiseService.hasSession(with: peerID) { initiateNoiseHandshake(with: peerID) } if !noiseService.hasSession(with: peerID) { initiateNoiseHandshake(with: peerID) }
SecureLogger.log("🕒 Queued DELIVERED ack for \(peerID) until handshake completes", SecureLogger.debug("🕒 Queued DELIVERED ack for \(peerID) until handshake completes", category: .session)
category: SecureLogger.session, level: .debug)
} }
} }
@@ -1916,8 +1899,7 @@ final class BLEService: NSObject {
return list return list
} }
guard !payloads.isEmpty else { return } guard !payloads.isEmpty else { return }
SecureLogger.log("📤 Sending \(payloads.count) pending noise payloads to \(peerID) after handshake", SecureLogger.debug("📤 Sending \(payloads.count) pending noise payloads to \(peerID) after handshake", category: .session)
category: SecureLogger.session, level: .debug)
for payload in payloads { for payload in payloads {
do { do {
let encrypted = try noiseService.encrypt(payload, for: peerID) let encrypted = try noiseService.encrypt(payload, for: peerID)
@@ -1932,8 +1914,7 @@ final class BLEService: NSObject {
) )
broadcastPacket(packet) broadcastPacket(packet)
} catch { } catch {
SecureLogger.log("❌ Failed to send pending noise payload to \(peerID): \(error)", SecureLogger.error("❌ Failed to send pending noise payload to \(peerID): \(error)")
category: SecureLogger.noise, level: .error)
} }
} }
} }
@@ -2103,8 +2084,7 @@ final class BLEService: NSObject {
// Cleanup: remove peers that are not connected and past reachability retention // Cleanup: remove peers that are not connected and past reachability retention
if !peer.isConnected { if !peer.isConnected {
if age > retention { if age > retention {
SecureLogger.log("🗑️ Removing stale peer after reachability window: \(peerID) (\(peer.nickname))", SecureLogger.debug("🗑️ Removing stale peer after reachability window: \(peerID) (\(peer.nickname))", category: .session)
category: SecureLogger.session, level: .debug)
peers.removeValue(forKey: peerID) peers.removeValue(forKey: peerID)
removedOfflineCount += 1 removedOfflineCount += 1
} }
@@ -2395,8 +2375,7 @@ extension BLEService: CBCentralManagerDelegate {
peripheral.delegate = self peripheral.delegate = self
// Connect to the peripheral with options for faster connection // Connect to the peripheral with options for faster connection
SecureLogger.log("📱 Connect: \(advertisedName) [RSSI:\(rssiValue)]", SecureLogger.debug("📱 Connect: \(advertisedName) [RSSI:\(rssiValue)]", category: .session)
category: SecureLogger.session, level: .debug)
// Use connection options for faster reconnection // Use connection options for faster reconnection
let options: [String: Any] = [ let options: [String: Any] = [
@@ -2415,8 +2394,7 @@ extension BLEService: CBCentralManagerDelegate {
state.isConnecting && !state.isConnected else { return } state.isConnecting && !state.isConnected else { return }
// Connection timed out - cancel it // Connection timed out - cancel it
SecureLogger.log("⏱️ Timeout: \(advertisedName)", SecureLogger.debug("⏱️ Timeout: \(advertisedName)", category: .session)
category: SecureLogger.session, level: .debug)
central.cancelPeripheralConnection(peripheral) central.cancelPeripheralConnection(peripheral)
self.peripherals[peripheralID] = nil self.peripherals[peripheralID] = nil
self.recentConnectTimeouts[peripheralID] = Date() self.recentConnectTimeouts[peripheralID] = Date()
@@ -2449,7 +2427,7 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
failureCounts[peripheralID] = 0 failureCounts[peripheralID] = 0
recentConnectTimeouts.removeValue(forKey: peripheralID) recentConnectTimeouts.removeValue(forKey: peripheralID)
SecureLogger.log("✅ Connected: \(peripheral.name ?? "Unknown") [\(peripheralID)]", category: SecureLogger.session, level: .debug) SecureLogger.debug("✅ Connected: \(peripheral.name ?? "Unknown") [\(peripheralID)]", category: .session)
// Discover services // Discover services
peripheral.discoverServices([BLEService.serviceUUID]) peripheral.discoverServices([BLEService.serviceUUID])
@@ -2461,8 +2439,7 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
// Find the peer ID if we have it // Find the peer ID if we have it
let peerID = peripherals[peripheralID]?.peerID let peerID = peripherals[peripheralID]?.peerID
SecureLogger.log("📱 Disconnect: \(peerID ?? peripheralID)\(error != nil ? " (\(error!.localizedDescription))" : "")", SecureLogger.debug("📱 Disconnect: \(peerID ?? peripheralID)\(error != nil ? " (\(error!.localizedDescription))" : "")", category: .session)
category: SecureLogger.session, level: .debug)
// If disconnect carried an error (often timeout), apply short backoff to avoid thrash // If disconnect carried an error (often timeout), apply short backoff to avoid thrash
if error != nil { if error != nil {
@@ -2517,7 +2494,7 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
// Clean up the references // Clean up the references
peripherals.removeValue(forKey: peripheralID) peripherals.removeValue(forKey: peripheralID)
SecureLogger.log("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: SecureLogger.session, level: .error) SecureLogger.error("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: .session)
failureCounts[peripheralID, default: 0] += 1 failureCounts[peripheralID, default: 0] += 1
// Try next candidate // Try next candidate
bleQueue.async { [weak self] in self?.tryConnectFromQueue() } bleQueue.async { [weak self] in self?.tryConnectFromQueue() }
@@ -2590,7 +2567,7 @@ extension BLEService {
] ]
central.connect(peripheral, options: options) central.connect(peripheral, options: options)
lastGlobalConnectAttempt = Date() lastGlobalConnectAttempt = Date()
SecureLogger.log("⏩ Queue connect: \(candidate.name) [RSSI:\(candidate.rssi)]", category: SecureLogger.session, level: .debug) SecureLogger.debug("⏩ Queue connect: \(candidate.name) [RSSI:\(candidate.rssi)]", category: .session)
} }
} }
@@ -2635,7 +2612,7 @@ extension BLEService {
extension BLEService: CBPeripheralDelegate { extension BLEService: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
if let error = error { if let error = error {
SecureLogger.log("❌ Error discovering services for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: SecureLogger.session, level: .error) SecureLogger.error("❌ Error discovering services for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session)
// Retry service discovery after a delay // Retry service discovery after a delay
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
guard peripheral.state == .connected else { return } guard peripheral.state == .connected else { return }
@@ -2645,7 +2622,7 @@ extension BLEService: CBPeripheralDelegate {
} }
guard let services = peripheral.services else { guard let services = peripheral.services else {
SecureLogger.log("⚠️ No services discovered for \(peripheral.name ?? "Unknown")", category: SecureLogger.session, level: .warning) SecureLogger.warning("⚠️ No services discovered for \(peripheral.name ?? "Unknown")", category: .session)
return return
} }
@@ -2661,12 +2638,12 @@ extension BLEService: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
if let error = error { if let error = error {
SecureLogger.log("❌ Error discovering characteristics for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: SecureLogger.session, level: .error) SecureLogger.error("❌ Error discovering characteristics for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session)
return return
} }
guard let characteristic = service.characteristics?.first(where: { $0.uuid == BLEService.characteristicUUID }) else { guard let characteristic = service.characteristics?.first(where: { $0.uuid == BLEService.characteristicUUID }) else {
SecureLogger.log("⚠️ No matching characteristic found for \(peripheral.name ?? "Unknown")", category: SecureLogger.session, level: .warning) SecureLogger.warning("⚠️ No matching characteristic found for \(peripheral.name ?? "Unknown")", category: .session)
return return
} }
@@ -2683,7 +2660,7 @@ extension BLEService: CBPeripheralDelegate {
// Verify characteristic supports reliable writes // Verify characteristic supports reliable writes
if !characteristic.properties.contains(.write) { if !characteristic.properties.contains(.write) {
SecureLogger.log("⚠️ Characteristic doesn't support reliable writes (withResponse)!", category: SecureLogger.session, level: .warning) SecureLogger.warning("⚠️ Characteristic doesn't support reliable writes (withResponse)!", category: .session)
} }
// Store characteristic in our consolidated structure // Store characteristic in our consolidated structure
@@ -2696,7 +2673,7 @@ extension BLEService: CBPeripheralDelegate {
// Subscribe for notifications // Subscribe for notifications
if characteristic.properties.contains(.notify) { if characteristic.properties.contains(.notify) {
peripheral.setNotifyValue(true, for: characteristic) peripheral.setNotifyValue(true, for: characteristic)
SecureLogger.log("🔔 Subscribed to notifications from \(peripheral.name ?? "Unknown")", category: SecureLogger.session, level: .debug) SecureLogger.debug("🔔 Subscribed to notifications from \(peripheral.name ?? "Unknown")", category: .session)
// Send announce after subscription is confirmed (force send for new connection) // Send announce after subscription is confirmed (force send for new connection)
messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostSubscribeAnnounceDelaySeconds) { [weak self] in messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostSubscribeAnnounceDelaySeconds) { [weak self] in
@@ -2707,18 +2684,18 @@ extension BLEService: CBPeripheralDelegate {
self?.rebroadcastRecentAnnounces() self?.rebroadcastRecentAnnounces()
} }
} else { } else {
SecureLogger.log("⚠️ Characteristic does not support notifications", category: SecureLogger.session, level: .warning) SecureLogger.warning("⚠️ Characteristic does not support notifications", category: .session)
} }
} }
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
if let error = error { if let error = error {
SecureLogger.log("❌ Error receiving notification: \(error.localizedDescription)", category: SecureLogger.session, level: .error) SecureLogger.error("❌ Error receiving notification: \(error.localizedDescription)", category: .session)
return return
} }
guard let data = characteristic.value else { guard let data = characteristic.value else {
SecureLogger.log("⚠️ No data in notification", category: SecureLogger.session, level: .warning) SecureLogger.warning("⚠️ No data in notification", category: .session)
return return
} }
@@ -2728,8 +2705,7 @@ extension BLEService: CBPeripheralDelegate {
guard let packet = BinaryProtocol.decode(data) else { guard let packet = BinaryProtocol.decode(data) else {
// Avoid dumping entire payload; log size and short prefix for diagnostics // Avoid dumping entire payload; log size and short prefix for diagnostics
let prefix = data.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ") let prefix = data.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")
SecureLogger.log("❌ Failed to decode notification packet (len=\(data.count), prefix=\(prefix))", SecureLogger.error("❌ Failed to decode notification packet (len=\(data.count), prefix=\(prefix))", category: .session)
category: SecureLogger.session, level: .error)
return return
} }
@@ -2737,7 +2713,7 @@ extension BLEService: CBPeripheralDelegate {
let senderID = packet.senderID.hexEncodedString() let senderID = packet.senderID.hexEncodedString()
// Only log non-announce packets // Only log non-announce packets
if packet.type != MessageType.announce.rawValue { if packet.type != MessageType.announce.rawValue {
SecureLogger.log("📦 Decoded notification packet type: \(packet.type) from sender: \(senderID)", category: SecureLogger.session, level: .debug) SecureLogger.debug("📦 Decoded notification packet type: \(packet.type) from sender: \(senderID)", category: .session)
} }
let peripheralUUID = peripheral.identifier.uuidString let peripheralUUID = peripheral.identifier.uuidString
@@ -2775,10 +2751,10 @@ extension BLEService: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
if let error = error { if let error = error {
SecureLogger.log("❌ Write failed to \(peripheral.name ?? peripheral.identifier.uuidString): \(error.localizedDescription)", category: SecureLogger.session, level: .error) SecureLogger.error("❌ Write failed to \(peripheral.name ?? peripheral.identifier.uuidString): \(error.localizedDescription)", category: .session)
// Don't retry - just log the error // Don't retry - just log the error
} else { } else {
SecureLogger.log("✅ Write confirmed to \(peripheral.name ?? peripheral.identifier.uuidString)", category: SecureLogger.session, level: .debug) SecureLogger.debug("✅ Write confirmed to \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session)
} }
} }
@@ -2788,14 +2764,14 @@ extension BLEService: CBPeripheralDelegate {
} }
func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) { func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) {
SecureLogger.log("⚠️ Services modified for \(peripheral.name ?? peripheral.identifier.uuidString)", category: SecureLogger.session, level: .warning) SecureLogger.warning("⚠️ Services modified for \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session)
// Check if our service was invalidated (peer app quit) // Check if our service was invalidated (peer app quit)
let hasOurService = peripheral.services?.contains { $0.uuid == BLEService.serviceUUID } ?? false let hasOurService = peripheral.services?.contains { $0.uuid == BLEService.serviceUUID } ?? false
if !hasOurService { if !hasOurService {
// Service is gone - disconnect // Service is gone - disconnect
SecureLogger.log("❌ BitChat service removed - disconnecting from \(peripheral.name ?? peripheral.identifier.uuidString)", category: SecureLogger.session, level: .warning) SecureLogger.warning("❌ BitChat service removed - disconnecting from \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session)
centralManager?.cancelPeripheralConnection(peripheral) centralManager?.cancelPeripheralConnection(peripheral)
} else { } else {
// Try to rediscover // Try to rediscover
@@ -2805,9 +2781,9 @@ extension BLEService: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
if let error = error { if let error = error {
SecureLogger.log("❌ Error updating notification state: \(error.localizedDescription)", category: SecureLogger.session, level: .error) SecureLogger.error("❌ Error updating notification state: \(error.localizedDescription)", category: .session)
} else { } else {
SecureLogger.log("🔔 Notification state updated for \(peripheral.name ?? peripheral.identifier.uuidString): \(characteristic.isNotifying ? "ON" : "OFF")", category: SecureLogger.session, level: .debug) SecureLogger.debug("🔔 Notification state updated for \(peripheral.name ?? peripheral.identifier.uuidString): \(characteristic.isNotifying ? "ON" : "OFF")", category: .session)
// If notifications are now on, send an announce to ensure this peer knows about us // If notifications are now on, send an announce to ensure this peer knows about us
if characteristic.isNotifying { if characteristic.isNotifying {
@@ -2822,7 +2798,7 @@ extension BLEService: CBPeripheralDelegate {
extension BLEService: CBPeripheralManagerDelegate { extension BLEService: CBPeripheralManagerDelegate {
func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) { func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
SecureLogger.log("📡 Peripheral manager state: \(peripheral.state.rawValue)", category: SecureLogger.session, level: .debug) SecureLogger.debug("📡 Peripheral manager state: \(peripheral.state.rawValue)", category: .session)
if peripheral.state == .poweredOn { if peripheral.state == .poweredOn {
// Remove all services first to ensure clean state // Remove all services first to ensure clean state
@@ -2841,28 +2817,28 @@ extension BLEService: CBPeripheralManagerDelegate {
service.characteristics = [characteristic!] service.characteristics = [characteristic!]
// Add service (advertising will start in didAdd delegate) // Add service (advertising will start in didAdd delegate)
SecureLogger.log("🔧 Adding BLE service...", category: SecureLogger.session, level: .debug) SecureLogger.debug("🔧 Adding BLE service...", category: .session)
peripheral.add(service) peripheral.add(service)
} }
} }
func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) { func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) {
if let error = error { if let error = error {
SecureLogger.log("❌ Failed to add service: \(error.localizedDescription)", category: SecureLogger.session, level: .error) SecureLogger.error("❌ Failed to add service: \(error.localizedDescription)", category: .session)
return return
} }
SecureLogger.log("✅ Service added successfully, starting advertising", category: SecureLogger.session, level: .debug) SecureLogger.debug("✅ Service added successfully, starting advertising", category: .session)
// Start advertising after service is confirmed added // Start advertising after service is confirmed added
let adData = buildAdvertisementData() let adData = buildAdvertisementData()
peripheral.startAdvertising(adData) peripheral.startAdvertising(adData)
SecureLogger.log("📡 Started advertising (LocalName: \((adData[CBAdvertisementDataLocalNameKey] as? String) != nil ? "on" : "off"), ID: \(myPeerID.prefix(8))…)", category: SecureLogger.session, level: .debug) SecureLogger.debug("📡 Started advertising (LocalName: \((adData[CBAdvertisementDataLocalNameKey] as? String) != nil ? "on" : "off"), ID: \(myPeerID.prefix(8))…)", category: .session)
} }
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) { func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) {
SecureLogger.log("📥 Central subscribed: \(central.identifier.uuidString)", category: SecureLogger.session, level: .debug) SecureLogger.debug("📥 Central subscribed: \(central.identifier.uuidString)", category: .session)
subscribedCentrals.append(central) subscribedCentrals.append(central)
// Send announce to the newly subscribed central after a small delay to avoid overwhelming // Send announce to the newly subscribed central after a small delay to avoid overwhelming
messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in
@@ -2875,12 +2851,12 @@ extension BLEService: CBPeripheralManagerDelegate {
} }
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) { func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) {
SecureLogger.log("📤 Central unsubscribed: \(central.identifier.uuidString)", category: SecureLogger.session, level: .debug) SecureLogger.debug("📤 Central unsubscribed: \(central.identifier.uuidString)", category: .session)
subscribedCentrals.removeAll { $0.identifier == central.identifier } subscribedCentrals.removeAll { $0.identifier == central.identifier }
// Ensure we're still advertising for other devices to find us // Ensure we're still advertising for other devices to find us
if peripheral.isAdvertising == false { if peripheral.isAdvertising == false {
SecureLogger.log("📡 Restarting advertising after central unsubscribed", category: SecureLogger.session, level: .debug) SecureLogger.debug("📡 Restarting advertising after central unsubscribed", category: .session)
peripheral.startAdvertising(buildAdvertisementData()) peripheral.startAdvertising(buildAdvertisementData())
} }
@@ -2914,7 +2890,7 @@ extension BLEService: CBPeripheralManagerDelegate {
} }
func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) { func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) {
SecureLogger.log("📤 Peripheral manager ready to send more notifications", category: SecureLogger.session, level: .debug) SecureLogger.debug("📤 Peripheral manager ready to send more notifications", category: .session)
// Retry pending notifications now that queue has space // Retry pending notifications now that queue has space
collectionsQueue.async(flags: .barrier) { [weak self] in collectionsQueue.async(flags: .barrier) { [weak self] in
@@ -2933,12 +2909,10 @@ extension BLEService: CBPeripheralManagerDelegate {
if !success { if !success {
// Still full, re-queue // Still full, re-queue
self.pendingNotifications.append((data: data, centrals: centrals)) self.pendingNotifications.append((data: data, centrals: centrals))
SecureLogger.log("⚠️ Notification queue still full, re-queuing", SecureLogger.debug("⚠️ Notification queue still full, re-queuing", category: .session)
category: SecureLogger.session, level: .debug)
break // Stop trying, wait for next ready callback break // Stop trying, wait for next ready callback
} else { } else {
SecureLogger.log("✅ Sent pending notification from retry queue", SecureLogger.debug("✅ Sent pending notification from retry queue", category: .session)
category: SecureLogger.session, level: .debug)
} }
} else { } else {
// Broadcast to all // Broadcast to all
@@ -2952,8 +2926,7 @@ extension BLEService: CBPeripheralManagerDelegate {
} }
if !self.pendingNotifications.isEmpty { if !self.pendingNotifications.isEmpty {
SecureLogger.log("📋 Still have \(self.pendingNotifications.count) pending notifications", SecureLogger.debug("📋 Still have \(self.pendingNotifications.count) pending notifications", category: .session)
category: SecureLogger.session, level: .debug)
} }
} }
} }
@@ -2961,7 +2934,7 @@ extension BLEService: CBPeripheralManagerDelegate {
func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) { func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) {
// Suppress logs for single write requests to reduce noise // Suppress logs for single write requests to reduce noise
if requests.count > 1 { if requests.count > 1 {
SecureLogger.log("📥 Received \(requests.count) write requests from central", category: SecureLogger.session, level: .debug) SecureLogger.debug("📥 Received \(requests.count) write requests from central", category: .session)
} }
// IMPORTANT: Respond immediately to prevent timeouts! // IMPORTANT: Respond immediately to prevent timeouts!
@@ -3000,7 +2973,7 @@ extension BLEService: CBPeripheralManagerDelegate {
if combined.count >= 2 { if combined.count >= 2 {
let peekType = combined[1] let peekType = combined[1]
if peekType != MessageType.announce.rawValue { if peekType != MessageType.announce.rawValue {
SecureLogger.log("📥 Accumulated write from central \(centralUUID): size=\(combined.count) (+\(appendedBytes)) bytes (type=\(peekType)), offsets=\(offsets)", category: SecureLogger.session, level: .debug) SecureLogger.debug("📥 Accumulated write from central \(centralUUID): size=\(combined.count) (+\(appendedBytes)) bytes (type=\(peekType)), offsets=\(offsets)", category: .session)
} }
} }
@@ -3010,7 +2983,7 @@ extension BLEService: CBPeripheralManagerDelegate {
pendingWriteBuffers.removeValue(forKey: centralUUID) pendingWriteBuffers.removeValue(forKey: centralUUID)
let senderID = packet.senderID.hexEncodedString() let senderID = packet.senderID.hexEncodedString()
if packet.type != MessageType.announce.rawValue { if packet.type != MessageType.announce.rawValue {
SecureLogger.log("📦 Decoded (combined) packet type: \(packet.type) from sender: \(senderID)", category: SecureLogger.session, level: .debug) SecureLogger.debug("📦 Decoded (combined) packet type: \(packet.type) from sender: \(senderID)", category: .session)
} }
if !subscribedCentrals.contains(sorted[0].central) { if !subscribedCentrals.contains(sorted[0].central) {
subscribedCentrals.append(sorted[0].central) subscribedCentrals.append(sorted[0].central)
@@ -3035,12 +3008,12 @@ extension BLEService: CBPeripheralManagerDelegate {
// If buffer grows suspiciously large, reset to avoid memory leak // If buffer grows suspiciously large, reset to avoid memory leak
if combined.count > TransportConfig.blePendingWriteBufferCapBytes { // cap for safety if combined.count > TransportConfig.blePendingWriteBufferCapBytes { // cap for safety
pendingWriteBuffers.removeValue(forKey: centralUUID) pendingWriteBuffers.removeValue(forKey: centralUUID)
SecureLogger.log("⚠️ Dropping oversized pending write buffer (\(combined.count) bytes) for central \(centralUUID)", category: SecureLogger.session, level: .warning) SecureLogger.warning("⚠️ Dropping oversized pending write buffer (\(combined.count) bytes) for central \(centralUUID)", category: .session)
} }
// If this was a single short write and still failed, log the raw chunk for debugging // If this was a single short write and still failed, log the raw chunk for debugging
if !hasMultiple, let only = sorted.first, let raw = only.value { if !hasMultiple, let only = sorted.first, let raw = only.value {
let prefix = raw.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ") let prefix = raw.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")
SecureLogger.log("❌ Failed to decode packet from central (len=\(raw.count), prefix=\(prefix))", category: SecureLogger.session, level: .error) SecureLogger.error("❌ Failed to decode packet from central (len=\(raw.count), prefix=\(prefix))", category: .session)
} }
} }
} }
+6 -6
View File
@@ -17,7 +17,7 @@ enum CommandResult {
/// Processes chat commands in a focused, efficient way /// Processes chat commands in a focused, efficient way
@MainActor @MainActor
class CommandProcessor { final class CommandProcessor {
weak var chatViewModel: ChatViewModel? weak var chatViewModel: ChatViewModel?
weak var meshService: Transport? weak var meshService: Transport?
@@ -50,9 +50,9 @@ class CommandProcessor {
case "/clear": case "/clear":
return handleClear() return handleClear()
case "/hug": case "/hug":
return handleEmote(args, action: "hugs", emoji: "🫂") return handleEmote(args, command: "hug", action: "hugs", emoji: "🫂")
case "/slap": case "/slap":
return handleEmote(args, action: "slaps", emoji: "🐟", suffix: " around a bit with a large trout") return handleEmote(args, command: "slap", action: "slaps", emoji: "🐟", suffix: " around a bit with a large trout")
case "/block": case "/block":
return handleBlock(args) return handleBlock(args)
case "/unblock": case "/unblock":
@@ -129,17 +129,17 @@ class CommandProcessor {
return .handled return .handled
} }
private func handleEmote(_ args: String, action: String, emoji: String, suffix: String = "") -> CommandResult { private func handleEmote(_ args: String, command: String, action: String, emoji: String, suffix: String = "") -> CommandResult {
let targetName = args.trimmingCharacters(in: .whitespaces) let targetName = args.trimmingCharacters(in: .whitespaces)
guard !targetName.isEmpty else { guard !targetName.isEmpty else {
return .error(message: "usage: /\(action) <nickname>") return .error(message: "usage: /\(command) <nickname>")
} }
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
guard let targetPeerID = chatViewModel?.getPeerIDForNickname(nickname), guard let targetPeerID = chatViewModel?.getPeerIDForNickname(nickname),
let myNickname = chatViewModel?.nickname else { let myNickname = chatViewModel?.nickname else {
return .error(message: "cannot \(action) \(nickname): not found") return .error(message: "cannot \(command) \(nickname): not found")
} }
let emoteContent = "* \(emoji) \(myNickname) \(action) \(nickname)\(suffix) *" let emoteContent = "* \(emoji) \(myNickname) \(action) \(nickname)\(suffix) *"
@@ -3,7 +3,7 @@ import Combine
/// Manages persistent favorite relationships between peers /// Manages persistent favorite relationships between peers
@MainActor @MainActor
class FavoritesPersistenceService: ObservableObject { final class FavoritesPersistenceService: ObservableObject {
struct FavoriteRelationship: Codable { struct FavoriteRelationship: Codable {
let peerNoisePublicKey: Data let peerNoisePublicKey: Data
@@ -13,12 +13,16 @@ class FavoritesPersistenceService: ObservableObject {
let theyFavoritedUs: Bool let theyFavoritedUs: Bool
let favoritedAt: Date let favoritedAt: Date
let lastUpdated: Date let lastUpdated: Date
// Track what we last sent as OUR npub to this peer, to avoid resending unless it changes
// Note: we do not track which npub we last sent to them; sending happens only on favorite toggle
var isMutual: Bool { var isMutual: Bool {
isFavorite && theyFavoritedUs isFavorite && theyFavoritedUs
} }
} }
// We intentionally do not track when we last sent our npub; sending happens only on favorite toggle.
private static let storageKey = "chat.bitchat.favorites" private static let storageKey = "chat.bitchat.favorites"
private static let keychainService = "chat.bitchat.favorites" private static let keychainService = "chat.bitchat.favorites"
@@ -47,8 +51,7 @@ class FavoritesPersistenceService: ObservableObject {
peerNostrPublicKey: String? = nil, peerNostrPublicKey: String? = nil,
peerNickname: String peerNickname: String
) { ) {
SecureLogger.log("⭐️ Adding favorite: \(peerNickname) (\(peerNoisePublicKey.hexEncodedString()))", SecureLogger.info("⭐️ Adding favorite: \(peerNickname) (\(peerNoisePublicKey.hexEncodedString()))", category: .session)
category: SecureLogger.session, level: .info)
let existing = favorites[peerNoisePublicKey] let existing = favorites[peerNoisePublicKey]
@@ -64,8 +67,7 @@ class FavoritesPersistenceService: ObservableObject {
// Log if this creates a mutual favorite // Log if this creates a mutual favorite
if relationship.isMutual { if relationship.isMutual {
SecureLogger.log("💕 Mutual favorite relationship established with \(peerNickname)!", SecureLogger.info("💕 Mutual favorite relationship established with \(peerNickname)!", category: .session)
category: SecureLogger.session, level: .info)
} }
favorites[peerNoisePublicKey] = relationship favorites[peerNoisePublicKey] = relationship
@@ -83,8 +85,7 @@ class FavoritesPersistenceService: ObservableObject {
func removeFavorite(peerNoisePublicKey: Data) { func removeFavorite(peerNoisePublicKey: Data) {
guard let existing = favorites[peerNoisePublicKey] else { return } guard let existing = favorites[peerNoisePublicKey] else { return }
SecureLogger.log("⭐️ Removing favorite: \(existing.peerNickname) (\(peerNoisePublicKey.hexEncodedString()))", SecureLogger.info("⭐️ Removing favorite: \(existing.peerNickname) (\(peerNoisePublicKey.hexEncodedString()))", category: .session)
category: SecureLogger.session, level: .info)
// If they still favorite us, keep the record but mark us as not favoriting // If they still favorite us, keep the record but mark us as not favoriting
if existing.theyFavoritedUs { if existing.theyFavoritedUs {
@@ -125,8 +126,7 @@ class FavoritesPersistenceService: ObservableObject {
let existing = favorites[peerNoisePublicKey] let existing = favorites[peerNoisePublicKey]
let displayName = peerNickname ?? existing?.peerNickname ?? "Unknown" let displayName = peerNickname ?? existing?.peerNickname ?? "Unknown"
SecureLogger.log("📨 Received favorite notification: \(displayName) \(favorited ? "favorited" : "unfavorited") us", SecureLogger.info("📨 Received favorite notification: \(displayName) \(favorited ? "favorited" : "unfavorited") us", category: .session)
category: SecureLogger.session, level: .info)
let relationship = FavoriteRelationship( let relationship = FavoriteRelationship(
peerNoisePublicKey: peerNoisePublicKey, peerNoisePublicKey: peerNoisePublicKey,
@@ -147,8 +147,7 @@ class FavoritesPersistenceService: ObservableObject {
// Check if this creates a mutual favorite // Check if this creates a mutual favorite
if relationship.isMutual { if relationship.isMutual {
SecureLogger.log("💕 Mutual favorite relationship established with \(displayName)!", SecureLogger.info("💕 Mutual favorite relationship established with \(displayName)!", category: .session)
category: SecureLogger.session, level: .info)
} }
} }
@@ -240,15 +239,13 @@ class FavoritesPersistenceService: ObservableObject {
/// Update noise public key when peer reconnects with new ID /// Update noise public key when peer reconnects with new ID
func updateNoisePublicKey(from oldKey: Data, to newKey: Data, peerNickname: String) { func updateNoisePublicKey(from oldKey: Data, to newKey: Data, peerNickname: String) {
guard let existing = favorites[oldKey] else { guard let existing = favorites[oldKey] else {
SecureLogger.log("⚠️ Cannot update noise key - no favorite found for \(oldKey.hexEncodedString())", SecureLogger.warning("⚠️ Cannot update noise key - no favorite found for \(oldKey.hexEncodedString())", category: .session)
category: SecureLogger.session, level: .warning)
return return
} }
// Check if we already have a favorite with the new key // Check if we already have a favorite with the new key
if favorites[newKey] != nil { if favorites[newKey] != nil {
SecureLogger.log("⚠️ Favorite already exists with new key \(newKey.hexEncodedString()), removing old entry", SecureLogger.warning("⚠️ Favorite already exists with new key \(newKey.hexEncodedString()), removing old entry", category: .session)
category: SecureLogger.session, level: .warning)
favorites.removeValue(forKey: oldKey) favorites.removeValue(forKey: oldKey)
saveFavorites() saveFavorites()
return return
@@ -302,7 +299,7 @@ class FavoritesPersistenceService: ObservableObject {
/// Clear all favorites - used for panic mode /// Clear all favorites - used for panic mode
func clearAllFavorites() { func clearAllFavorites() {
SecureLogger.log("🧹 Clearing all favorites (panic mode)", category: SecureLogger.session, level: .warning) SecureLogger.warning("🧹 Clearing all favorites (panic mode)", category: .session)
favorites.removeAll() favorites.removeAll()
saveFavorites() saveFavorites()
@@ -336,7 +333,7 @@ class FavoritesPersistenceService: ObservableObject {
// Successfully saved favorites // Successfully saved favorites
} catch { } catch {
SecureLogger.log("Failed to save favorites: \(error)", category: SecureLogger.session, level: .error) SecureLogger.error("Failed to save favorites: \(error)", category: .session)
} }
} }
@@ -354,14 +351,12 @@ class FavoritesPersistenceService: ObservableObject {
let decoder = JSONDecoder() let decoder = JSONDecoder()
let relationships = try decoder.decode([FavoriteRelationship].self, from: data) let relationships = try decoder.decode([FavoriteRelationship].self, from: data)
SecureLogger.log("✅ Loaded \(relationships.count) favorite relationships", SecureLogger.info("✅ Loaded \(relationships.count) favorite relationships", category: .session)
category: SecureLogger.session, level: .info)
// Log Nostr public key info // Log Nostr public key info
for relationship in relationships { for relationship in relationships {
if relationship.peerNostrPublicKey == nil { if relationship.peerNostrPublicKey == nil {
SecureLogger.log("⚠️ No Nostr public key stored for '\(relationship.peerNickname)'", SecureLogger.warning("⚠️ No Nostr public key stored for '\(relationship.peerNickname)'", category: .session)
category: SecureLogger.session, level: .warning)
} }
} }
@@ -372,8 +367,7 @@ class FavoritesPersistenceService: ObservableObject {
for relationship in relationships { for relationship in relationships {
// Check for duplicates by public key (the actual unique identifier) // Check for duplicates by public key (the actual unique identifier)
if let existing = seenPublicKeys[relationship.peerNoisePublicKey] { if let existing = seenPublicKeys[relationship.peerNoisePublicKey] {
SecureLogger.log("⚠️ Duplicate favorite found for public key \(relationship.peerNoisePublicKey.hexEncodedString()) - nicknames: '\(existing.peerNickname)' vs '\(relationship.peerNickname)'", SecureLogger.warning("⚠️ Duplicate favorite found for public key \(relationship.peerNoisePublicKey.hexEncodedString()) - nicknames: '\(existing.peerNickname)' vs '\(relationship.peerNickname)'", category: .session)
category: SecureLogger.session, level: .warning)
// Keep the most recent or most complete relationship // Keep the most recent or most complete relationship
if relationship.lastUpdated > existing.lastUpdated || if relationship.lastUpdated > existing.lastUpdated ||
@@ -414,7 +408,7 @@ class FavoritesPersistenceService: ObservableObject {
// Log loaded relationships // Log loaded relationships
// Loaded relationships successfully // Loaded relationships successfully
} catch { } catch {
SecureLogger.log("Failed to load favorites: \(error)", category: SecureLogger.session, level: .error) SecureLogger.error("Failed to load favorites: \(error)", category: .session)
} }
} }
} }
+9 -10
View File
@@ -8,9 +8,8 @@
import Foundation import Foundation
import Security import Security
import os.log
class KeychainManager { final class KeychainManager {
static let shared = KeychainManager() static let shared = KeychainManager()
// Use consistent service name for all keychain items // Use consistent service name for all keychain items
@@ -53,7 +52,7 @@ class KeychainManager {
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool { func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
let fullKey = "identity_\(key)" let fullKey = "identity_\(key)"
let result = saveData(keyData, forKey: fullKey) let result = saveData(keyData, forKey: fullKey)
SecureLogger.logKeyOperation("save", keyType: key, success: result) SecureLogger.logKeyOperation(.save, keyType: key, success: result)
return result return result
} }
@@ -64,7 +63,7 @@ class KeychainManager {
func deleteIdentityKey(forKey key: String) -> Bool { func deleteIdentityKey(forKey key: String) -> Bool {
let result = delete(forKey: "identity_\(key)") let result = delete(forKey: "identity_\(key)")
SecureLogger.logKeyOperation("delete", keyType: key, success: result) SecureLogger.logKeyOperation(.delete, keyType: key, success: result)
return result return result
} }
@@ -113,9 +112,9 @@ class KeychainManager {
if status == errSecSuccess { return true } if status == errSecSuccess { return true }
if status == -34018 && !triedWithoutGroup { if status == -34018 && !triedWithoutGroup {
SecureLogger.logError(NSError(domain: "Keychain", code: -34018), context: "Missing keychain entitlement", category: SecureLogger.keychain) SecureLogger.error(NSError(domain: "Keychain", code: -34018), context: "Missing keychain entitlement", category: .keychain)
} else if status != errSecDuplicateItem { } else if status != errSecDuplicateItem {
SecureLogger.logError(NSError(domain: "Keychain", code: Int(status)), context: "Error saving to keychain", category: SecureLogger.keychain) SecureLogger.error(NSError(domain: "Keychain", code: Int(status)), context: "Error saving to keychain", category: .keychain)
} }
return false return false
} }
@@ -151,7 +150,7 @@ class KeychainManager {
if status == errSecSuccess { return result as? Data } if status == errSecSuccess { return result as? Data }
if status == -34018 { if status == -34018 {
SecureLogger.logError(NSError(domain: "Keychain", code: -34018), context: "Missing keychain entitlement", category: SecureLogger.keychain) SecureLogger.error(NSError(domain: "Keychain", code: -34018), context: "Missing keychain entitlement", category: .keychain)
} }
return nil return nil
} }
@@ -198,7 +197,7 @@ class KeychainManager {
// Delete ALL keychain data for panic mode // Delete ALL keychain data for panic mode
func deleteAllKeychainData() -> Bool { func deleteAllKeychainData() -> Bool {
SecureLogger.log("Panic mode - deleting all keychain data", category: SecureLogger.security, level: .warning) SecureLogger.warning("Panic mode - deleting all keychain data", category: .security)
var totalDeleted = 0 var totalDeleted = 0
@@ -261,7 +260,7 @@ class KeychainManager {
let deleteStatus = SecItemDelete(deleteQuery as CFDictionary) let deleteStatus = SecItemDelete(deleteQuery as CFDictionary)
if deleteStatus == errSecSuccess { if deleteStatus == errSecSuccess {
totalDeleted += 1 totalDeleted += 1
SecureLogger.log("Deleted keychain item: \(account) from \(service)", category: SecureLogger.keychain, level: .info) SecureLogger.info("Deleted keychain item: \(account) from \(service)", category: .keychain)
} }
} }
} }
@@ -303,7 +302,7 @@ class KeychainManager {
totalDeleted += 1 totalDeleted += 1
} }
SecureLogger.log("Panic mode cleanup completed. Total items deleted: \(totalDeleted)", category: SecureLogger.keychain, level: .warning) SecureLogger.warning("Panic mode cleanup completed. Total items deleted: \(totalDeleted)", category: .keychain)
return totalDeleted > 0 return totalDeleted > 0
} }
@@ -197,8 +197,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
// Surface as denied/restricted if relevant; otherwise keep previous state // Surface as denied/restricted if relevant; otherwise keep previous state
SecureLogger.log("LocationChannelManager: location error: \(error.localizedDescription)", SecureLogger.error("LocationChannelManager: location error: \(error.localizedDescription)", category: .session)
category: SecureLogger.session, level: .error)
} }
// MARK: - Helpers // MARK: - Helpers
+10 -18
View File
@@ -39,40 +39,34 @@ final class MessageRouter {
func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) { func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
let reachableMesh = mesh.isPeerReachable(peerID) let reachableMesh = mesh.isPeerReachable(peerID)
if reachableMesh { if reachableMesh {
SecureLogger.log("Routing PM via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))", SecureLogger.debug("Routing PM via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))", category: .session)
category: SecureLogger.session, level: .debug)
// BLEService will initiate a handshake if needed and queue the message // BLEService will initiate a handshake if needed and queue the message
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
} else if canSendViaNostr(peerID: peerID) { } else if canSendViaNostr(peerID: peerID) {
SecureLogger.log("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))", SecureLogger.debug("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))", category: .session)
category: SecureLogger.session, level: .debug)
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
} else { } else {
// Queue for later (when mesh connects or Nostr mapping appears) // Queue for later (when mesh connects or Nostr mapping appears)
if outbox[peerID] == nil { outbox[peerID] = [] } if outbox[peerID] == nil { outbox[peerID] = [] }
outbox[peerID]?.append((content, recipientNickname, messageID)) outbox[peerID]?.append((content, recipientNickname, messageID))
SecureLogger.log("Queued PM for \(peerID.prefix(8))… (no mesh, no Nostr mapping) id=\(messageID.prefix(8))", SecureLogger.debug("Queued PM for \(peerID.prefix(8))… (no mesh, no Nostr mapping) id=\(messageID.prefix(8))", category: .session)
category: SecureLogger.session, level: .debug)
} }
} }
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) { func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
// Prefer mesh for reachable peers; BLE will queue if handshake is needed // Prefer mesh for reachable peers; BLE will queue if handshake is needed
if mesh.isPeerReachable(peerID) { if mesh.isPeerReachable(peerID) {
SecureLogger.log("Routing READ ack via mesh (reachable) to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))", SecureLogger.debug("Routing READ ack via mesh (reachable) to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))", category: .session)
category: SecureLogger.session, level: .debug)
mesh.sendReadReceipt(receipt, to: peerID) mesh.sendReadReceipt(receipt, to: peerID)
} else { } else {
SecureLogger.log("Routing READ ack via Nostr to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))", SecureLogger.debug("Routing READ ack via Nostr to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))", category: .session)
category: SecureLogger.session, level: .debug)
nostr.sendReadReceipt(receipt, to: peerID) nostr.sendReadReceipt(receipt, to: peerID)
} }
} }
func sendDeliveryAck(_ messageID: String, to peerID: String) { func sendDeliveryAck(_ messageID: String, to peerID: String) {
if mesh.isPeerReachable(peerID) { if mesh.isPeerReachable(peerID) {
SecureLogger.log("Routing DELIVERED ack via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))", SecureLogger.debug("Routing DELIVERED ack via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))", category: .session)
category: SecureLogger.session, level: .debug)
mesh.sendDeliveryAck(for: messageID, to: peerID) mesh.sendDeliveryAck(for: messageID, to: peerID)
} else { } else {
nostr.sendDeliveryAck(for: messageID, to: peerID) nostr.sendDeliveryAck(for: messageID, to: peerID)
@@ -80,6 +74,7 @@ final class MessageRouter {
} }
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) { func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
// Route via mesh when connected; else use Nostr
if mesh.isPeerConnected(peerID) { if mesh.isPeerConnected(peerID) {
mesh.sendFavoriteNotification(to: peerID, isFavorite: isFavorite) mesh.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
} else { } else {
@@ -108,18 +103,15 @@ final class MessageRouter {
func flushOutbox(for peerID: String) { func flushOutbox(for peerID: String) {
guard let queued = outbox[peerID], !queued.isEmpty else { return } guard let queued = outbox[peerID], !queued.isEmpty else { return }
SecureLogger.log("Flushing outbox for \(peerID.prefix(8))… count=\(queued.count)", SecureLogger.debug("Flushing outbox for \(peerID.prefix(8))… count=\(queued.count)", category: .session)
category: SecureLogger.session, level: .debug)
var remaining: [(content: String, nickname: String, messageID: String)] = [] var remaining: [(content: String, nickname: String, messageID: String)] = []
// Prefer mesh if connected; else try Nostr if mapping exists // Prefer mesh if connected; else try Nostr if mapping exists
for (content, nickname, messageID) in queued { for (content, nickname, messageID) in queued {
if mesh.isPeerReachable(peerID) { if mesh.isPeerReachable(peerID) {
SecureLogger.log("Outbox -> mesh for \(peerID.prefix(8))… id=\(messageID.prefix(8))", SecureLogger.debug("Outbox -> mesh for \(peerID.prefix(8))… id=\(messageID.prefix(8))", category: .session)
category: SecureLogger.session, level: .debug)
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID) mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
} else if canSendViaNostr(peerID: peerID) { } else if canSendViaNostr(peerID: peerID) {
SecureLogger.log("Outbox -> Nostr for \(peerID.prefix(8))… id=\(messageID.prefix(8))", SecureLogger.debug("Outbox -> Nostr for \(peerID.prefix(8))… id=\(messageID.prefix(8))", category: .session)
category: SecureLogger.session, level: .debug)
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID) nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
} else { } else {
// Keep unsent items queued // Keep unsent items queued
+19 -20
View File
@@ -85,7 +85,6 @@
import Foundation import Foundation
import CryptoKit import CryptoKit
import os.log
// MARK: - Encryption Status // MARK: - Encryption Status
@@ -135,7 +134,7 @@ enum EncryptionStatus: Equatable {
/// Provides a high-level API for establishing secure channels between peers, /// Provides a high-level API for establishing secure channels between peers,
/// handling all cryptographic operations transparently. /// handling all cryptographic operations transparently.
/// - Important: This service maintains the device's cryptographic identity /// - Important: This service maintains the device's cryptographic identity
class NoiseEncryptionService { final class NoiseEncryptionService {
// Static identity key (persistent across sessions) // Static identity key (persistent across sessions)
private let staticIdentityKey: Curve25519.KeyAgreement.PrivateKey private let staticIdentityKey: Curve25519.KeyAgreement.PrivateKey
public let staticIdentityPublicKey: Curve25519.KeyAgreement.PublicKey public let staticIdentityPublicKey: Curve25519.KeyAgreement.PublicKey
@@ -190,7 +189,7 @@ class NoiseEncryptionService {
if let identityData = KeychainManager.shared.getIdentityKey(forKey: "noiseStaticKey"), if let identityData = KeychainManager.shared.getIdentityKey(forKey: "noiseStaticKey"),
let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: identityData) { let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: identityData) {
loadedKey = key loadedKey = key
SecureLogger.logKeyOperation("load", keyType: "noiseStaticKey", success: true) SecureLogger.logKeyOperation(.load, keyType: "noiseStaticKey", success: true)
} }
// If no identity exists, create new one // If no identity exists, create new one
else { else {
@@ -199,7 +198,7 @@ class NoiseEncryptionService {
// Save to keychain // Save to keychain
let saved = KeychainManager.shared.saveIdentityKey(keyData, forKey: "noiseStaticKey") let saved = KeychainManager.shared.saveIdentityKey(keyData, forKey: "noiseStaticKey")
SecureLogger.logKeyOperation("create", keyType: "noiseStaticKey", success: saved) SecureLogger.logKeyOperation(.create, keyType: "noiseStaticKey", success: saved)
} }
// Now assign the final value // Now assign the final value
@@ -213,7 +212,7 @@ class NoiseEncryptionService {
if let signingData = KeychainManager.shared.getIdentityKey(forKey: "ed25519SigningKey"), if let signingData = KeychainManager.shared.getIdentityKey(forKey: "ed25519SigningKey"),
let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingData) { let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingData) {
loadedSigningKey = key loadedSigningKey = key
SecureLogger.logKeyOperation("load", keyType: "ed25519SigningKey", success: true) SecureLogger.logKeyOperation(.load, keyType: "ed25519SigningKey", success: true)
} }
// If no signing key exists, create new one // If no signing key exists, create new one
else { else {
@@ -222,7 +221,7 @@ class NoiseEncryptionService {
// Save to keychain // Save to keychain
let saved = KeychainManager.shared.saveIdentityKey(keyData, forKey: "ed25519SigningKey") let saved = KeychainManager.shared.saveIdentityKey(keyData, forKey: "ed25519SigningKey")
SecureLogger.logKeyOperation("create", keyType: "ed25519SigningKey", success: saved) SecureLogger.logKeyOperation(.create, keyType: "ed25519SigningKey", success: saved)
} }
// Now assign the signing keys // Now assign the signing keys
@@ -269,8 +268,8 @@ class NoiseEncryptionService {
// Clear from keychain // Clear from keychain
let deletedStatic = KeychainManager.shared.deleteIdentityKey(forKey: "noiseStaticKey") let deletedStatic = KeychainManager.shared.deleteIdentityKey(forKey: "noiseStaticKey")
let deletedSigning = KeychainManager.shared.deleteIdentityKey(forKey: "ed25519SigningKey") let deletedSigning = KeychainManager.shared.deleteIdentityKey(forKey: "ed25519SigningKey")
SecureLogger.logKeyOperation("delete", keyType: "identity keys", success: deletedStatic && deletedSigning) SecureLogger.logKeyOperation(.delete, keyType: "identity keys", success: deletedStatic && deletedSigning)
SecureLogger.log("Panic mode activated - identity cleared", category: SecureLogger.security, level: .warning) SecureLogger.warning("Panic mode activated - identity cleared", category: .security)
// Stop rekey timer // Stop rekey timer
stopRekeyTimer() stopRekeyTimer()
} }
@@ -281,7 +280,7 @@ class NoiseEncryptionService {
let signature = try signingKey.signature(for: data) let signature = try signingKey.signature(for: data)
return signature return signature
} catch { } catch {
SecureLogger.logError(error, context: "Failed to sign data", category: SecureLogger.noise) SecureLogger.error(error, context: "Failed to sign data")
return nil return nil
} }
} }
@@ -292,7 +291,7 @@ class NoiseEncryptionService {
let signingPublicKey = try Curve25519.Signing.PublicKey(rawRepresentation: publicKey) let signingPublicKey = try Curve25519.Signing.PublicKey(rawRepresentation: publicKey)
return signingPublicKey.isValidSignature(signature, for: data) return signingPublicKey.isValidSignature(signature, for: data)
} catch { } catch {
SecureLogger.logError(error, context: "Failed to verify signature", category: SecureLogger.noise) SecureLogger.error(error, context: "Failed to verify signature")
return false return false
} }
} }
@@ -392,17 +391,17 @@ class NoiseEncryptionService {
// Validate peer ID // Validate peer ID
guard NoiseSecurityValidator.validatePeerID(peerID) else { guard NoiseSecurityValidator.validatePeerID(peerID) else {
SecureLogger.logSecurityEvent(.authenticationFailed(peerID: peerID), level: .warning) SecureLogger.warning(.authenticationFailed(peerID: peerID))
throw NoiseSecurityError.invalidPeerID throw NoiseSecurityError.invalidPeerID
} }
// Check rate limit // Check rate limit
guard rateLimiter.allowHandshake(from: peerID) else { guard rateLimiter.allowHandshake(from: peerID) else {
SecureLogger.logSecurityEvent(.authenticationFailed(peerID: "Rate limited: \(peerID)"), level: .warning) SecureLogger.warning(.authenticationFailed(peerID: "Rate limited: \(peerID)"))
throw NoiseSecurityError.rateLimitExceeded throw NoiseSecurityError.rateLimitExceeded
} }
SecureLogger.logSecurityEvent(.handshakeStarted(peerID: peerID)) SecureLogger.info(.handshakeStarted(peerID: peerID))
// Return raw handshake data without wrapper // Return raw handshake data without wrapper
// The Noise protocol handles its own message format // The Noise protocol handles its own message format
@@ -415,19 +414,19 @@ class NoiseEncryptionService {
// Validate peer ID // Validate peer ID
guard NoiseSecurityValidator.validatePeerID(peerID) else { guard NoiseSecurityValidator.validatePeerID(peerID) else {
SecureLogger.logSecurityEvent(.authenticationFailed(peerID: peerID), level: .warning) SecureLogger.warning(.authenticationFailed(peerID: peerID))
throw NoiseSecurityError.invalidPeerID throw NoiseSecurityError.invalidPeerID
} }
// Validate message size // Validate message size
guard NoiseSecurityValidator.validateHandshakeMessageSize(message) else { guard NoiseSecurityValidator.validateHandshakeMessageSize(message) else {
SecureLogger.logSecurityEvent(.handshakeFailed(peerID: peerID, error: "Message too large"), level: .warning) SecureLogger.warning(.handshakeFailed(peerID: peerID, error: "Message too large"))
throw NoiseSecurityError.messageTooLarge throw NoiseSecurityError.messageTooLarge
} }
// Check rate limit // Check rate limit
guard rateLimiter.allowHandshake(from: peerID) else { guard rateLimiter.allowHandshake(from: peerID) else {
SecureLogger.logSecurityEvent(.authenticationFailed(peerID: "Rate limited: \(peerID)"), level: .warning) SecureLogger.warning(.authenticationFailed(peerID: "Rate limited: \(peerID)"))
throw NoiseSecurityError.rateLimitExceeded throw NoiseSecurityError.rateLimitExceeded
} }
@@ -521,7 +520,7 @@ class NoiseEncryptionService {
peerFingerprints.removeValue(forKey: peerID) peerFingerprints.removeValue(forKey: peerID)
} }
SecureLogger.logSecurityEvent(.sessionExpired(peerID: peerID)) SecureLogger.info(.sessionExpired(peerID: peerID))
} }
// MARK: - Private Helpers // MARK: - Private Helpers
@@ -537,7 +536,7 @@ class NoiseEncryptionService {
} }
// Log security event // Log security event
SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID)) SecureLogger.info(.handshakeCompleted(peerID: peerID))
// Notify all handlers about authentication // Notify all handlers about authentication
serviceQueue.async { [weak self] in serviceQueue.async { [weak self] in
@@ -573,12 +572,12 @@ class NoiseEncryptionService {
// Attempt to rekey the session // Attempt to rekey the session
do { do {
try sessionManager.initiateRekey(for: peerID) try sessionManager.initiateRekey(for: peerID)
SecureLogger.log("Key rotation initiated for peer: \(peerID)", category: SecureLogger.security, level: .debug) SecureLogger.debug("Key rotation initiated for peer: \(peerID)", category: .security)
// Signal that handshake is needed // Signal that handshake is needed
onHandshakeRequired?(peerID) onHandshakeRequired?(peerID)
} catch { } catch {
SecureLogger.logError(error, context: "Failed to initiate rekey for peer: \(peerID)", category: SecureLogger.session) SecureLogger.error(error, context: "Failed to initiate rekey for peer: \(peerID)", category: .session)
} }
} }
} }
+24 -36
View File
@@ -51,31 +51,29 @@ final class NostrTransport: Transport {
Task { @MainActor in Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return } guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return } guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
SecureLogger.log("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.prefix(8))… id=\(messageID.prefix(8))", SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.prefix(8))… id=\(messageID.prefix(8))", category: .session)
category: SecureLogger.session, level: .debug)
// Convert recipient npub -> hex (x-only) // Convert recipient npub -> hex (x-only)
let recipientHex: String let recipientHex: String
do { do {
let (hrp, data) = try Bech32.decode(recipientNpub) let (hrp, data) = try Bech32.decode(recipientNpub)
guard hrp == "npub" else { guard hrp == "npub" else {
SecureLogger.log("NostrTransport: recipient key not npub (hrp=\(hrp))", category: SecureLogger.session, level: .error) SecureLogger.error("NostrTransport: recipient key not npub (hrp=\(hrp))", category: .session)
return return
} }
recipientHex = data.hexEncodedString() recipientHex = data.hexEncodedString()
} catch { } catch {
SecureLogger.log("NostrTransport: failed to decode npub -> hex: \(error)", category: SecureLogger.session, level: .error) SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
return return
} }
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else { guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
SecureLogger.log("NostrTransport: failed to embed PM packet", category: SecureLogger.session, level: .error) SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
return return
} }
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else { guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.log("NostrTransport: failed to build Nostr event for PM", category: SecureLogger.session, level: .error) SecureLogger.error("NostrTransport: failed to build Nostr event for PM", category: .session)
return return
} }
SecureLogger.log("NostrTransport: sending PM giftWrap id=\(event.id.prefix(16))", SecureLogger.debug("NostrTransport: sending PM giftWrap id=\(event.id.prefix(16))", category: .session)
category: SecureLogger.session, level: .debug)
NostrRelayManager.shared.sendEvent(event) NostrRelayManager.shared.sendEvent(event)
} }
} }
@@ -99,8 +97,7 @@ final class NostrTransport: Transport {
Task { @MainActor in Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return } guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return }
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return } guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
SecureLogger.log("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))", SecureLogger.debug("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))", category: .session)
category: SecureLogger.session, level: .debug)
// Convert recipient npub -> hex // Convert recipient npub -> hex
let recipientHex: String let recipientHex: String
do { do {
@@ -109,15 +106,14 @@ final class NostrTransport: Transport {
recipientHex = data.hexEncodedString() recipientHex = data.hexEncodedString()
} catch { scheduleNextReadAck(); return } } catch { scheduleNextReadAck(); return }
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else { guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else {
SecureLogger.log("NostrTransport: failed to embed READ ack", category: SecureLogger.session, level: .error) SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
scheduleNextReadAck(); return scheduleNextReadAck(); return
} }
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else { guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.log("NostrTransport: failed to build Nostr event for READ ack", category: SecureLogger.session, level: .error) SecureLogger.error("NostrTransport: failed to build Nostr event for READ ack", category: .session)
scheduleNextReadAck(); return scheduleNextReadAck(); return
} }
SecureLogger.log("NostrTransport: sending READ ack giftWrap id=\(event.id.prefix(16))", SecureLogger.debug("NostrTransport: sending READ ack giftWrap id=\(event.id.prefix(16))", category: .session)
category: SecureLogger.session, level: .debug)
NostrRelayManager.shared.sendEvent(event) NostrRelayManager.shared.sendEvent(event)
scheduleNextReadAck() scheduleNextReadAck()
} }
@@ -136,8 +132,7 @@ final class NostrTransport: Transport {
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return } guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return } guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)" let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
SecureLogger.log("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))", SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))", category: .session)
category: SecureLogger.session, level: .debug)
// Convert recipient npub -> hex // Convert recipient npub -> hex
let recipientHex: String let recipientHex: String
do { do {
@@ -146,15 +141,14 @@ final class NostrTransport: Transport {
recipientHex = data.hexEncodedString() recipientHex = data.hexEncodedString()
} catch { return } } catch { return }
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else { guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
SecureLogger.log("NostrTransport: failed to embed favorite notification", category: SecureLogger.session, level: .error) SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
return return
} }
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else { guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.log("NostrTransport: failed to build Nostr event for favorite notification", category: SecureLogger.session, level: .error) SecureLogger.error("NostrTransport: failed to build Nostr event for favorite notification", category: .session)
return return
} }
SecureLogger.log("NostrTransport: sending favorite giftWrap id=\(event.id.prefix(16))", SecureLogger.debug("NostrTransport: sending favorite giftWrap id=\(event.id.prefix(16))", category: .session)
category: SecureLogger.session, level: .debug)
NostrRelayManager.shared.sendEvent(event) NostrRelayManager.shared.sendEvent(event)
} }
} }
@@ -180,8 +174,7 @@ final class NostrTransport: Transport {
Task { @MainActor in Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return } guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return } guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
SecureLogger.log("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))", SecureLogger.debug("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))", category: .session)
category: SecureLogger.session, level: .debug)
let recipientHex: String let recipientHex: String
do { do {
let (hrp, data) = try Bech32.decode(recipientNpub) let (hrp, data) = try Bech32.decode(recipientNpub)
@@ -189,15 +182,14 @@ final class NostrTransport: Transport {
recipientHex = data.hexEncodedString() recipientHex = data.hexEncodedString()
} catch { return } } catch { return }
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else { guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
SecureLogger.log("NostrTransport: failed to embed DELIVERED ack", category: SecureLogger.session, level: .error) SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
return return
} }
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else { guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.log("NostrTransport: failed to build Nostr event for DELIVERED ack", category: SecureLogger.session, level: .error) SecureLogger.error("NostrTransport: failed to build Nostr event for DELIVERED ack", category: .session)
return return
} }
SecureLogger.log("NostrTransport: sending DELIVERED ack giftWrap id=\(event.id.prefix(16))", SecureLogger.debug("NostrTransport: sending DELIVERED ack giftWrap id=\(event.id.prefix(16))", category: .session)
category: SecureLogger.session, level: .debug)
NostrRelayManager.shared.sendEvent(event) NostrRelayManager.shared.sendEvent(event)
} }
} }
@@ -205,8 +197,7 @@ final class NostrTransport: Transport {
// MARK: - Geohash ACK helpers // MARK: - Geohash ACK helpers
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
Task { @MainActor in Task { @MainActor in
SecureLogger.log("GeoDM: send DELIVERED -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))", SecureLogger.debug("GeoDM: send DELIVERED -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))", category: .session)
category: SecureLogger.session, level: .debug)
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return } guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return } guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
NostrRelayManager.registerPendingGiftWrap(id: event.id) NostrRelayManager.registerPendingGiftWrap(id: event.id)
@@ -216,8 +207,7 @@ final class NostrTransport: Transport {
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
Task { @MainActor in Task { @MainActor in
SecureLogger.log("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))", SecureLogger.debug("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))", category: .session)
category: SecureLogger.session, level: .debug)
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return } guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return } guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
NostrRelayManager.registerPendingGiftWrap(id: event.id) NostrRelayManager.registerPendingGiftWrap(id: event.id)
@@ -229,19 +219,17 @@ final class NostrTransport: Transport {
func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) { func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
Task { @MainActor in Task { @MainActor in
guard !recipientHex.isEmpty else { return } guard !recipientHex.isEmpty else { return }
SecureLogger.log("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))", SecureLogger.debug("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))", category: .session)
category: SecureLogger.session, level: .debug)
// Build embedded BitChat packet without recipient peer ID // Build embedded BitChat packet without recipient peer ID
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else { guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else {
SecureLogger.log("NostrTransport: failed to embed geohash PM packet", category: SecureLogger.session, level: .error) SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
return return
} }
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else {
SecureLogger.log("NostrTransport: failed to build Nostr event for geohash PM", category: SecureLogger.session, level: .error) SecureLogger.error("NostrTransport: failed to build Nostr event for geohash PM", category: .session)
return return
} }
SecureLogger.log("NostrTransport: sending geohash PM giftWrap id=\(event.id.prefix(16))", SecureLogger.debug("NostrTransport: sending geohash PM giftWrap id=\(event.id.prefix(16))", category: .session)
category: SecureLogger.session, level: .debug)
NostrRelayManager.registerPendingGiftWrap(id: event.id) NostrRelayManager.registerPendingGiftWrap(id: event.id)
NostrRelayManager.shared.sendEvent(event) NostrRelayManager.shared.sendEvent(event)
} }
+1 -1
View File
@@ -14,7 +14,7 @@ import UIKit
import AppKit import AppKit
#endif #endif
class NotificationService { final class NotificationService {
static let shared = NotificationService() static let shared = NotificationService()
private init() {} private init() {}
+2 -3
View File
@@ -10,7 +10,7 @@ import Foundation
import SwiftUI import SwiftUI
/// Manages all private chat functionality /// Manages all private chat functionality
class PrivateChatManager: ObservableObject { final class PrivateChatManager: ObservableObject {
@Published var privateChats: [String: [BitchatMessage]] = [:] @Published var privateChats: [String: [BitchatMessage]] = [:]
@Published var selectedPeer: String? = nil @Published var selectedPeer: String? = nil
@Published var unreadMessages: Set<String> = [] @Published var unreadMessages: Set<String> = []
@@ -228,8 +228,7 @@ class PrivateChatManager: ObservableObject {
// Route via MessageRouter to avoid handshakeRequired spam when session isn't established // Route via MessageRouter to avoid handshakeRequired spam when session isn't established
if let router = messageRouter { if let router = messageRouter {
SecureLogger.log("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.prefix(8))… via router", SecureLogger.debug("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.prefix(8))… via router", category: .session)
category: SecureLogger.session, level: .debug)
Task { @MainActor in Task { @MainActor in
router.sendReadReceipt(receipt, to: senderPeerID) router.sendReadReceipt(receipt, to: senderPeerID)
} }
+13 -13
View File
@@ -189,7 +189,7 @@ final class TorManager: ObservableObject {
var started = false var started = false
// If already running (per C glue), treat as started // If already running (per C glue), treat as started
if tor_host_is_running() != 0 { if tor_host_is_running() != 0 {
SecureLogger.log("TorManager: embed reports already running", category: SecureLogger.session, level: .info) SecureLogger.info("TorManager: embed reports already running", category: .session)
return true return true
} }
dir.withCString { dptr in dir.withCString { dptr in
@@ -198,9 +198,9 @@ final class TorManager: ObservableObject {
let rc = tor_host_start(dptr, sptr, cptr, 1) let rc = tor_host_start(dptr, sptr, cptr, 1)
started = (rc == 0) started = (rc == 0)
if rc != 0 { if rc != 0 {
SecureLogger.log("TorManager: tor_host_start failed rc=\(rc)", category: SecureLogger.session, level: .error) SecureLogger.error("TorManager: tor_host_start failed rc=\(rc)", category: .session)
} else { } else {
SecureLogger.log("TorManager: tor_host_start OK (\(socks), control \(control))", category: SecureLogger.session, level: .info) SecureLogger.info("TorManager: tor_host_start OK (\(socks), control \(control))", category: .session)
} }
} }
} }
@@ -215,10 +215,10 @@ final class TorManager: ObservableObject {
await MainActor.run { await MainActor.run {
self.socksReady = ready self.socksReady = ready
if ready { if ready {
SecureLogger.log("TorManager: SOCKS ready at \(self.socksHost):\(self.socksPort) [embed]", category: SecureLogger.session, level: .info) SecureLogger.info("TorManager: SOCKS ready at \(self.socksHost):\(self.socksPort) [embed]", category: .session)
} else { } else {
self.lastError = NSError(domain: "TorManager", code: -14, userInfo: [NSLocalizedDescriptionKey: "Tor SOCKS not reachable after embed start"]) self.lastError = NSError(domain: "TorManager", code: -14, userInfo: [NSLocalizedDescriptionKey: "Tor SOCKS not reachable after embed start"])
SecureLogger.log("TorManager: SOCKS not reachable (timeout) [embed]", category: SecureLogger.session, level: .error) SecureLogger.error("TorManager: SOCKS not reachable (timeout) [embed]", category: .session)
} }
} }
} }
@@ -282,13 +282,13 @@ final class TorManager: ObservableObject {
/// Returns true if the attempt started and port probing was scheduled. /// Returns true if the attempt started and port probing was scheduled.
private func startTorViaDlopen() -> Bool { private func startTorViaDlopen() -> Bool {
guard let fwURL = frameworkBinaryURL() else { guard let fwURL = frameworkBinaryURL() else {
SecureLogger.log("TorManager: no embedded tor framework found", category: SecureLogger.session, level: .warning) SecureLogger.warning("TorManager: no embedded tor framework found", category: .session)
return false return false
} }
// Load the library // Load the library
let mode = RTLD_NOW | RTLD_LOCAL let mode = RTLD_NOW | RTLD_LOCAL
SecureLogger.log("TorManager: dlopen(\(fwURL.lastPathComponent))…", category: SecureLogger.session, level: .info) SecureLogger.info("TorManager: dlopen(\(fwURL.lastPathComponent))…", category: .session)
guard let handle = dlopen(fwURL.path, mode) else { guard let handle = dlopen(fwURL.path, mode) else {
let err = String(cString: dlerror()) let err = String(cString: dlerror())
self.lastError = NSError(domain: "TorManager", code: -10, userInfo: [NSLocalizedDescriptionKey: "dlopen failed: \(err)"]) self.lastError = NSError(domain: "TorManager", code: -10, userInfo: [NSLocalizedDescriptionKey: "dlopen failed: \(err)"])
@@ -314,7 +314,7 @@ final class TorManager: ObservableObject {
argv.append(contentsOf: ["-f", torrc]) argv.append(contentsOf: ["-f", torrc])
} }
// Run Tor on a background thread to avoid blocking the main actor // Run Tor on a background thread to avoid blocking the main actor
SecureLogger.log("TorManager: launching tor_main with torrc", category: SecureLogger.session, level: .info) SecureLogger.info("TorManager: launching tor_main with torrc", category: .session)
let argc = Int32(argv.count) let argc = Int32(argv.count)
DispatchQueue.global(qos: .utility).async { DispatchQueue.global(qos: .utility).async {
// Build stable C argv in this thread // Build stable C argv in this thread
@@ -339,9 +339,9 @@ final class TorManager: ObservableObject {
self.socksReady = ready self.socksReady = ready
if !ready { if !ready {
self.lastError = NSError(domain: "TorManager", code: -12, userInfo: [NSLocalizedDescriptionKey: "Tor SOCKS not reachable after dlopen start"]) self.lastError = NSError(domain: "TorManager", code: -12, userInfo: [NSLocalizedDescriptionKey: "Tor SOCKS not reachable after dlopen start"])
SecureLogger.log("TorManager: SOCKS not reachable (timeout)", category: SecureLogger.session, level: .error) SecureLogger.error("TorManager: SOCKS not reachable (timeout)", category: .session)
} else { } else {
SecureLogger.log("TorManager: SOCKS ready at \(self.socksHost):\(self.socksPort)", category: SecureLogger.session, level: .info) SecureLogger.info("TorManager: SOCKS ready at \(self.socksHost):\(self.socksPort)", category: .session)
} }
// isStarting will be cleared when bootstrap reaches 100% // isStarting will be cleared when bootstrap reaches 100%
} }
@@ -385,7 +385,7 @@ final class TorManager: ObservableObject {
var argv: [String] = ["tor"] var argv: [String] = ["tor"]
if let torrc = torrcURL()?.path { argv.append(contentsOf: ["-f", torrc]) } if let torrc = torrcURL()?.path { argv.append(contentsOf: ["-f", torrc]) }
SecureLogger.log("TorManager: starting tor_main (static)", category: SecureLogger.session, level: .info) SecureLogger.info("TorManager: starting tor_main (static)", category: .session)
let argc = Int32(argv.count) let argc = Int32(argv.count)
DispatchQueue.global(qos: .utility).async { DispatchQueue.global(qos: .utility).async {
// Build stable C argv in this thread // Build stable C argv in this thread
@@ -409,10 +409,10 @@ final class TorManager: ObservableObject {
await MainActor.run { await MainActor.run {
self.socksReady = ready self.socksReady = ready
if ready { if ready {
SecureLogger.log("TorManager: SOCKS ready at \(self.socksHost):\(self.socksPort)", category: SecureLogger.session, level: .info) SecureLogger.info("TorManager: SOCKS ready at \(self.socksHost):\(self.socksPort)", category: .session)
} else { } else {
self.lastError = NSError(domain: "TorManager", code: -13, userInfo: [NSLocalizedDescriptionKey: "Tor SOCKS not reachable after static start"]) self.lastError = NSError(domain: "TorManager", code: -13, userInfo: [NSLocalizedDescriptionKey: "Tor SOCKS not reachable after static start"])
SecureLogger.log("TorManager: SOCKS not reachable (timeout)", category: SecureLogger.session, level: .error) SecureLogger.error("TorManager: SOCKS not reachable (timeout)", category: .session)
} }
// isStarting will be cleared when bootstrap reaches 100% // isStarting will be cleared when bootstrap reaches 100%
} }
+2
View File
@@ -37,6 +37,8 @@ enum TransportConfig {
// UI thresholds // UI thresholds
static let uiLateInsertThreshold: TimeInterval = 15.0 static let uiLateInsertThreshold: TimeInterval = 15.0
// Geohash public chats are more sensitive to ordering; use a tighter threshold
static let uiLateInsertThresholdGeo: TimeInterval = 0.0
static let uiProcessedNostrEventsCap: Int = 2000 static let uiProcessedNostrEventsCap: Int = 2000
static let uiChannelInactivityThresholdSeconds: TimeInterval = 9 * 60 static let uiChannelInactivityThresholdSeconds: TimeInterval = 9 * 60
+5 -34
View File
@@ -13,7 +13,7 @@ import CryptoKit
/// Single source of truth for peer state, combining mesh connectivity and favorites /// Single source of truth for peer state, combining mesh connectivity and favorites
@MainActor @MainActor
class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
// MARK: - Published Properties // MARK: - Published Properties
@@ -195,31 +195,6 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
let favoriteStatus = favorites[noiseKey] { let favoriteStatus = favorites[noiseKey] {
peer.favoriteStatus = favoriteStatus peer.favoriteStatus = favoriteStatus
peer.nostrPublicKey = favoriteStatus.peerNostrPublicKey peer.nostrPublicKey = favoriteStatus.peerNostrPublicKey
} else {
// Check by nickname for reconnected peers
let favoriteByNickname = favorites.values.first {
$0.peerNickname == peerInfo.nickname
}
if let favorite = favoriteByNickname,
let noiseKey = peerInfo.noisePublicKey {
SecureLogger.log(
"🔄 Found favorite for '\(peerInfo.nickname)' by nickname, updating noise key",
category: SecureLogger.session,
level: .debug
)
// Update the favorite's key in persistence
favoritesService.updateNoisePublicKey(
from: favorite.peerNoisePublicKey,
to: noiseKey,
peerNickname: peerInfo.nickname
)
// Get updated favorite
peer.favoriteStatus = favoritesService.getFavoriteStatus(for: noiseKey)
peer.nostrPublicKey = peer.favoriteStatus?.peerNostrPublicKey ?? favorite.peerNostrPublicKey
}
} }
return peer return peer
@@ -282,8 +257,7 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
/// Toggle favorite status /// Toggle favorite status
func toggleFavorite(_ peerID: String) { func toggleFavorite(_ peerID: String) {
guard let peer = getPeer(by: peerID) else { guard let peer = getPeer(by: peerID) else {
SecureLogger.log("⚠️ Cannot toggle favorite - peer not found: \(peerID)", SecureLogger.warning("⚠️ Cannot toggle favorite - peer not found: \(peerID)", category: .session)
category: SecureLogger.session, level: .warning)
return return
} }
@@ -293,15 +267,13 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
var actualNickname = peer.nickname var actualNickname = peer.nickname
// Debug logging to understand the issue // Debug logging to understand the issue
SecureLogger.log("🔍 Toggle favorite - peer.nickname: '\(peer.nickname)', peer.displayName: '\(peer.displayName)', peerID: \(peerID)", SecureLogger.debug("🔍 Toggle favorite - peer.nickname: '\(peer.nickname)', peer.displayName: '\(peer.displayName)', peerID: \(peerID)", category: .session)
category: SecureLogger.session, level: .debug)
if actualNickname.isEmpty { if actualNickname.isEmpty {
// Try to get from mesh service's current peer list // Try to get from mesh service's current peer list
if let meshPeerNickname = meshService.peerNickname(peerID: peerID) { if let meshPeerNickname = meshService.peerNickname(peerID: peerID) {
actualNickname = meshPeerNickname actualNickname = meshPeerNickname
SecureLogger.log("🔍 Got nickname from mesh service: '\(actualNickname)'", SecureLogger.debug("🔍 Got nickname from mesh service: '\(actualNickname)'", category: .session)
category: SecureLogger.session, level: .debug)
} }
} }
@@ -328,8 +300,7 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
} }
// Log the final nickname being saved // Log the final nickname being saved
SecureLogger.log("⭐️ Toggled favorite for '\(finalNickname)' (peerID: \(peerID), was: \(wasFavorite), now: \(!wasFavorite))", SecureLogger.debug("⭐️ Toggled favorite for '\(finalNickname)' (peerID: \(peerID), was: \(wasFavorite), now: \(!wasFavorite))", category: .session)
category: SecureLogger.session, level: .debug)
// Send favorite notification to the peer via router (mesh or Nostr) // Send favorite notification to the peer via router (mesh or Nostr)
if let router = messageRouter { if let router = messageRouter {
+20
View File
@@ -0,0 +1,20 @@
//
// OSLog+Categories.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import os.log
extension OSLog {
private static let subsystem = "chat.bitchat"
static let noise = OSLog(subsystem: subsystem, category: "noise")
static let encryption = OSLog(subsystem: subsystem, category: "encryption")
static let keychain = OSLog(subsystem: subsystem, category: "keychain")
static let session = OSLog(subsystem: subsystem, category: "session")
static let security = OSLog(subsystem: subsystem, category: "security")
static let handshake = OSLog(subsystem: subsystem, category: "handshake")
}
+94 -73
View File
@@ -11,18 +11,7 @@ import os.log
/// Centralized security-aware logging framework /// Centralized security-aware logging framework
/// Provides safe logging that filters sensitive data and security events /// Provides safe logging that filters sensitive data and security events
class SecureLogger { final class SecureLogger {
// MARK: - Log Categories
private static let subsystem = "chat.bitchat"
static let noise = OSLog(subsystem: subsystem, category: "noise")
static let encryption = OSLog(subsystem: subsystem, category: "encryption")
static let keychain = OSLog(subsystem: subsystem, category: "keychain")
static let session = OSLog(subsystem: subsystem, category: "session")
static let security = OSLog(subsystem: subsystem, category: "security")
static let handshake = OSLog(subsystem: subsystem, category: "handshake")
// MARK: - Timestamp Formatter // MARK: - Timestamp Formatter
@@ -124,24 +113,90 @@ class SecureLogger {
// MARK: - Public Logging Methods // MARK: - Public Logging Methods
/// Log a security event static func debug(_ message: @autoclosure () -> String, category: OSLog = .noise,
static func logSecurityEvent(_ event: SecurityEvent, level: LogLevel = .info, file: String = #file, line: Int = #line, function: String = #function) {
file: String = #file, line: Int = #line, function: String = #function) { log(message(), category: category, level: .debug, file: file, line: line, function: function)
guard shouldLog(level) else { return }
let location = formatLocation(file: file, line: line, function: function)
let message = "\(location) \(event.message)"
#if DEBUG
os_log("%{public}@", log: security, type: level.osLogType, message)
#else
// In release, use private logging to prevent sensitive data exposure
os_log("%{private}@", log: security, type: level.osLogType, message)
#endif
} }
static func info(_ message: @autoclosure () -> String, category: OSLog = .noise,
file: String = #file, line: Int = #line, function: String = #function) {
log(message(), category: category, level: .info, file: file, line: line, function: function)
}
static func warning(_ message: @autoclosure () -> String, category: OSLog = .noise,
file: String = #file, line: Int = #line, function: String = #function) {
log(message(), category: category, level: .warning, file: file, line: line, function: function)
}
static func error(_ message: @autoclosure () -> String, category: OSLog = .noise,
file: String = #file, line: Int = #line, function: String = #function) {
log(message(), category: category, level: .error, file: file, line: line, function: function)
}
// MARK: Security Event Logging
static func debug(_ event: SecurityEvent, file: String = #file, line: Int = #line, function: String = #function) {
logSecurityEvent(event, level: .debug, file: file, line: line, function: function)
}
static func info(_ event: SecurityEvent, file: String = #file, line: Int = #line, function: String = #function) {
logSecurityEvent(event, level: .info, file: file, line: line, function: function)
}
static func warning(_ event: SecurityEvent, file: String = #file, line: Int = #line, function: String = #function) {
logSecurityEvent(event, level: .warning, file: file, line: line, function: function)
}
static func error(_ event: SecurityEvent, file: String = #file, line: Int = #line, function: String = #function) {
logSecurityEvent(event, level: .error, file: file, line: line, function: function)
}
/// Log errors with context
static func error(_ error: Error, context: @autoclosure () -> String, category: OSLog = .noise,
file: String = #file, line: Int = #line, function: String = #function) {
let location = formatLocation(file: file, line: line, function: function)
let sanitized = sanitize(context())
let errorDesc = sanitize(error.localizedDescription)
#if DEBUG
os_log("%{public}@ Error in %{public}@: %{public}@", log: category, type: .error, location, sanitized, errorDesc)
#else
os_log("%{private}@ Error in %{private}@: %{private}@", log: category, type: .error, location, sanitized, errorDesc)
#endif
}
}
// MARK: - Convenience Extensions
extension SecureLogger {
enum KeyOperation: String, CustomStringConvertible {
case load
case create
case generate
case delete
case save
var description: String { rawValue }
}
/// Log key management operations
static func logKeyOperation(_ operation: KeyOperation, keyType: String, success: Bool = true,
file: String = #file, line: Int = #line, function: String = #function) {
if success {
debug("Key operation '\(operation)' for \(keyType) succeeded", category: .keychain, file: file, line: line, function: function)
} else {
error("Key operation '\(operation)' for \(keyType) failed", category: .keychain, file: file, line: line, function: function)
}
}
}
// MARK: - Private Helpers
private extension SecureLogger {
/// Log general messages with automatic sensitive data filtering /// Log general messages with automatic sensitive data filtering
static func log(_ message: @autoclosure () -> String, category: OSLog = noise, level: LogLevel = .debug, static func log(_ message: @autoclosure () -> String, category: OSLog, level: LogLevel,
file: String = #file, line: Int = #line, function: String = #function) { file: String, line: Int, function: String) {
guard shouldLog(level) else { return } guard shouldLog(level) else { return }
let location = formatLocation(file: file, line: line, function: function) let location = formatLocation(file: file, line: line, function: function)
let sanitized = sanitize("\(location) \(message())") let sanitized = sanitize("\(location) \(message())")
@@ -156,31 +211,30 @@ class SecureLogger {
#endif #endif
} }
/// Log errors with context /// Log a security event
static func logError(_ error: Error, context: @autoclosure () -> String, category: OSLog = noise, static func logSecurityEvent(_ event: SecurityEvent, level: LogLevel = .info,
file: String = #file, line: Int = #line, function: String = #function) { file: String, line: Int, function: String) {
guard shouldLog(level) else { return }
let location = formatLocation(file: file, line: line, function: function) let location = formatLocation(file: file, line: line, function: function)
let sanitized = sanitize(context()) let message = "\(location) \(event.message)"
let errorDesc = sanitize(error.localizedDescription)
#if DEBUG #if DEBUG
os_log("%{public}@ Error in %{public}@: %{public}@", log: category, type: .error, location, sanitized, errorDesc) os_log("%{public}@", log: .security, type: level.osLogType, message)
#else #else
os_log("%{private}@ Error in %{private}@: %{private}@", log: category, type: .error, location, sanitized, errorDesc) // In release, use private logging to prevent sensitive data exposure
os_log("%{private}@", log: .security, type: level.osLogType, message)
#endif #endif
} }
// MARK: - Private Helpers
/// Format location information for logging /// Format location information for logging
private static func formatLocation(file: String, line: Int, function: String) -> String { static func formatLocation(file: String, line: Int, function: String) -> String {
let fileName = (file as NSString).lastPathComponent let fileName = (file as NSString).lastPathComponent
let timestamp = timestampFormatter.string(from: Date()) let timestamp = timestampFormatter.string(from: Date())
return "[\(timestamp)] [\(fileName):\(line) \(function)]" return "[\(timestamp)] [\(fileName):\(line) \(function)]"
} }
/// Sanitize strings to remove potentially sensitive data /// Sanitize strings to remove potentially sensitive data
private static func sanitize(_ input: String) -> String { static func sanitize(_ input: String) -> String {
let key = input as NSString let key = input as NSString
// Check cache first // Check cache first
@@ -226,45 +280,12 @@ class SecureLogger {
} }
/// Sanitize individual values /// Sanitize individual values
private static func sanitize<T>(_ value: T) -> String { static func sanitize<T>(_ value: T) -> String {
let stringValue = String(describing: value) let stringValue = String(describing: value)
return sanitize(stringValue) return sanitize(stringValue)
} }
} }
// MARK: - Convenience Extensions
extension SecureLogger {
/// Log handshake events
static func logHandshake(_ phase: String, peerID: String, success: Bool = true,
file: String = #file, line: Int = #line, function: String = #function) {
if success {
log("Handshake \(phase) with peer: \(peerID)", category: session, level: .info,
file: file, line: line, function: function)
} else {
log("Handshake \(phase) failed with peer: \(peerID)", category: session, level: .warning,
file: file, line: line, function: function)
}
}
/// Log encryption operations
static func logEncryption(_ operation: String, success: Bool = true,
file: String = #file, line: Int = #line, function: String = #function) {
let level: LogLevel = success ? .debug : .error
log("Encryption operation '\(operation)' \(success ? "succeeded" : "failed")",
category: encryption, level: level, file: file, line: line, function: function)
}
/// Log key management operations
static func logKeyOperation(_ operation: String, keyType: String, success: Bool = true,
file: String = #file, line: Int = #line, function: String = #function) {
let level: LogLevel = success ? .debug : .error
log("Key operation '\(operation)' for \(keyType) \(success ? "succeeded" : "failed")",
category: keychain, level: level, file: file, line: line, function: function)
}
}
// MARK: - Migration Helper // MARK: - Migration Helper
/// Helper to migrate from print statements to SecureLogger /// Helper to migrate from print statements to SecureLogger
@@ -273,6 +294,6 @@ func secureLog(_ items: Any..., separator: String = " ", terminator: String = "\
file: String = #file, line: Int = #line, function: String = #function) { file: String = #file, line: Int = #line, function: String = #function) {
#if DEBUG #if DEBUG
let message = items.map { String(describing: $0) }.joined(separator: separator) let message = items.map { String(describing: $0) }.joined(separator: separator)
SecureLogger.log(message, level: .debug, file: file, line: line, function: function) SecureLogger.debug(message, file: file, line: line, function: function)
#endif #endif
} }
+153 -164
View File
@@ -90,7 +90,7 @@ import UIKit
/// Manages the application state and business logic for BitChat. /// Manages the application state and business logic for BitChat.
/// Acts as the primary coordinator between UI components and backend services, /// Acts as the primary coordinator between UI components and backend services,
/// implementing the BitchatDelegate protocol to handle network events. /// implementing the BitchatDelegate protocol to handle network events.
class ChatViewModel: ObservableObject, BitchatDelegate { final class ChatViewModel: ObservableObject, BitchatDelegate {
// Precompiled regexes and detectors reused across formatting // Precompiled regexes and detectors reused across formatting
private enum Regexes { private enum Regexes {
static let hashtag: NSRegularExpression = { static let hashtag: NSRegularExpression = {
@@ -420,6 +420,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
private var geoSamplingSubs: [String: String] = [:] // subID -> geohash private var geoSamplingSubs: [String: String] = [:] // subID -> geohash
private var lastGeoNotificationAt: [String: Date] = [:] // geohash -> last notify time private var lastGeoNotificationAt: [String: Date] = [:] // geohash -> last notify time
// MARK: - Message Delivery Tracking // MARK: - Message Delivery Tracking
// Delivery tracking // Delivery tracking
@@ -462,8 +463,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if let data = try? JSONEncoder().encode(Array(sentReadReceipts)) { if let data = try? JSONEncoder().encode(Array(sentReadReceipts)) {
UserDefaults.standard.set(data, forKey: "sentReadReceipts") UserDefaults.standard.set(data, forKey: "sentReadReceipts")
} else { } else {
SecureLogger.log("❌ Failed to encode read receipts for persistence", SecureLogger.error("❌ Failed to encode read receipts for persistence", category: .session)
category: SecureLogger.session, level: .error)
} }
} }
} }
@@ -562,11 +562,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Wait for Tor readiness before Nostr init // Wait for Tor readiness before Nostr init
let ready = await TorManager.shared.awaitReady(timeout: 60) let ready = await TorManager.shared.awaitReady(timeout: 60)
guard ready else { guard ready else {
SecureLogger.log("Nostr init skipped: Tor not ready", category: SecureLogger.session, level: .error) SecureLogger.error("Nostr init skipped: Tor not ready", category: .session)
return return
} }
nostrRelayManager = NostrRelayManager.shared nostrRelayManager = NostrRelayManager.shared
SecureLogger.log("Initializing Nostr relay connections", category: SecureLogger.session, level: .debug) SecureLogger.debug("Initializing Nostr relay connections", category: .session)
// Connect is managed centrally on scene activation; avoid duplicate connects here // Connect is managed centrally on scene activation; avoid duplicate connects here
// Attempt to flush any queued outbox after Nostr comes online // Attempt to flush any queued outbox after Nostr comes online
@@ -596,8 +596,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if uniquePeers[peer.id] == nil { if uniquePeers[peer.id] == nil {
uniquePeers[peer.id] = peer uniquePeers[peer.id] = peer
} else { } else {
SecureLogger.log("⚠️ Duplicate peer ID detected: \(peer.id) (\(peer.displayName))", SecureLogger.warning("⚠️ Duplicate peer ID detected: \(peer.id) (\(peer.displayName))", category: .session)
category: SecureLogger.session, level: .warning)
} }
} }
self.peerIndex = uniquePeers self.peerIndex = uniquePeers
@@ -899,7 +898,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if Date().timeIntervalSince(eventTime) < 15 { return } if Date().timeIntervalSince(eventTime) < 15 { return }
} }
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 { if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
let nick = nickTag[1] let nick = nickTag[1].trimmingCharacters(in: .whitespacesAndNewlines)
self.geoNicknames[event.pubkey.lowercased()] = nick self.geoNicknames[event.pubkey.lowercased()] = nick
} }
// Store mapping for geohash sender IDs used in messages (ensures consistent colors) // Store mapping for geohash sender IDs used in messages (ensures consistent colors)
@@ -930,7 +929,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} }
let senderName = self.displayNameForNostrPubkey(event.pubkey) let senderName = self.displayNameForNostrPubkey(event.pubkey)
let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines) let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
let timestamp = Date(timeIntervalSince1970: TimeInterval(event.created_at)) // Clamp future timestamps to now to avoid future-dated messages skewing order
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let timestamp = min(rawTs, Date())
let mentions = self.parseMentions(from: content) let mentions = self.parseMentions(from: content)
let msg = BitchatMessage( let msg = BitchatMessage(
id: event.id, id: event.id,
@@ -1035,11 +1036,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if let idx = self.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) { if let idx = self.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) {
self.privateChats[convKey]?[idx].deliveryStatus = .delivered(to: self.displayNameForNostrPubkey(senderPubkey), at: Date()) self.privateChats[convKey]?[idx].deliveryStatus = .delivered(to: self.displayNameForNostrPubkey(senderPubkey), at: Date())
self.objectWillChange.send() self.objectWillChange.send()
SecureLogger.log("GeoDM: recv DELIVERED for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))", SecureLogger.info("GeoDM: recv DELIVERED for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))", category: .session)
category: SecureLogger.session, level: .info)
} else { } else {
SecureLogger.log("GeoDM: delivered ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", SecureLogger.warning("GeoDM: delivered ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session)
category: SecureLogger.session, level: .warning)
} }
} }
case .readReceipt: case .readReceipt:
@@ -1047,11 +1046,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if let idx = self.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) { if let idx = self.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) {
self.privateChats[convKey]?[idx].deliveryStatus = .read(by: self.displayNameForNostrPubkey(senderPubkey), at: Date()) self.privateChats[convKey]?[idx].deliveryStatus = .read(by: self.displayNameForNostrPubkey(senderPubkey), at: Date())
self.objectWillChange.send() self.objectWillChange.send()
SecureLogger.log("GeoDM: recv READ for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))", SecureLogger.info("GeoDM: recv READ for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))", category: .session)
category: SecureLogger.session, level: .info)
} else { } else {
SecureLogger.log("GeoDM: read ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", SecureLogger.warning("GeoDM: read ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session)
category: SecureLogger.session, level: .warning)
} }
} }
case .verifyChallenge, .verifyResponse: case .verifyChallenge, .verifyResponse:
@@ -1464,14 +1461,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
count: TransportConfig.nostrGeoRelayCount count: TransportConfig.nostrGeoRelayCount
) )
if targetRelays.isEmpty { if targetRelays.isEmpty {
SecureLogger.log("Geo: no geohash relays available for \(ch.geohash); not sending", category: SecureLogger.session, level: .warning) SecureLogger.warning("Geo: no geohash relays available for \(ch.geohash); not sending", category: .session)
} else { } else {
NostrRelayManager.shared.sendEvent(event, to: targetRelays) NostrRelayManager.shared.sendEvent(event, to: targetRelays)
} }
// Track ourselves as active participant // Track ourselves as active participant
self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex) self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
SecureLogger.log("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(LocationChannelManager.shared.teleported)", SecureLogger.debug("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(LocationChannelManager.shared.teleported)", category: .session)
category: SecureLogger.session, level: .debug)
// If we tagged this as teleported, also mark our pubkey in teleportedGeo for UI // If we tagged this as teleported, also mark our pubkey in teleportedGeo for UI
// Only when not in our regional set (and regional list is known) // Only when not in our regional set (and regional list is known)
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
@@ -1479,11 +1475,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if LocationChannelManager.shared.teleported && hasRegional && !inRegional { if LocationChannelManager.shared.teleported && hasRegional && !inRegional {
let key = identity.publicKeyHex.lowercased() let key = identity.publicKeyHex.lowercased()
self.teleportedGeo = self.teleportedGeo.union([key]) self.teleportedGeo = self.teleportedGeo.union([key])
SecureLogger.log("GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(self.teleportedGeo.count)", SecureLogger.info("GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(self.teleportedGeo.count)", category: .session)
category: SecureLogger.session, level: .info)
} }
} catch { } catch {
SecureLogger.log("❌ Failed to send geohash message: \(error)", category: SecureLogger.session, level: .error) SecureLogger.error("❌ Failed to send geohash message: \(error)", category: .session)
self.addSystemMessage("failed to send to location channel") self.addSystemMessage("failed to send to location channel")
} }
} }
@@ -1510,7 +1505,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Debug: log if any empty messages are present // Debug: log if any empty messages are present
let emptyMesh = messages.filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count let emptyMesh = messages.filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count
if emptyMesh > 0 { if emptyMesh > 0 {
SecureLogger.log("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: SecureLogger.session, level: .debug) SecureLogger.debug("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: .session)
} }
stopGeoParticipantsTimer() stopGeoParticipantsTimer()
geohashPeople = [] geohashPeople = []
@@ -1537,7 +1532,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Debug: log if any empty messages are present post-sanitize // Debug: log if any empty messages are present post-sanitize
let emptyGeo = messages.filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count let emptyGeo = messages.filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count
if emptyGeo > 0 { if emptyGeo > 0 {
SecureLogger.log("RenderGuard: geohash \(ch.geohash) timeline has \(emptyGeo) empty messages after sanitize", category: SecureLogger.session, level: .debug) SecureLogger.debug("RenderGuard: geohash \(ch.geohash) timeline has \(emptyGeo) empty messages after sanitize", category: .session)
} }
} }
// If switching to a location channel, flush any pending geohash-only system messages // If switching to a location channel, flush any pending geohash-only system messages
@@ -1569,8 +1564,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let key = id.publicKeyHex.lowercased() let key = id.publicKeyHex.lowercased()
if LocationChannelManager.shared.teleported && hasRegional && !inRegional { if LocationChannelManager.shared.teleported && hasRegional && !inRegional {
teleportedGeo = teleportedGeo.union([key]) teleportedGeo = teleportedGeo.union([key])
SecureLogger.log("GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", SecureLogger.info("GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", category: .session)
category: SecureLogger.session, level: .info)
} else { } else {
teleportedGeo.remove(key) teleportedGeo.remove(key)
} }
@@ -1593,8 +1587,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
self.recordProcessedEvent(event.id) self.recordProcessedEvent(event.id)
// Log incoming tags for diagnostics // Log incoming tags for diagnostics
let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",") let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",")
SecureLogger.log("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)", SecureLogger.debug("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)", category: .session)
category: SecureLogger.session, level: .debug)
// Track teleport tag for participants only our format ["t", "teleport"] // Track teleport tag for participants only our format ["t", "teleport"]
let hasTeleportTag: Bool = event.tags.contains(where: { tag in let hasTeleportTag: Bool = event.tags.contains(where: { tag in
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport" tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
@@ -1611,8 +1604,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if !isSelf { if !isSelf {
Task { @MainActor in Task { @MainActor in
self.teleportedGeo = self.teleportedGeo.union([key]) self.teleportedGeo = self.teleportedGeo.union([key])
SecureLogger.log("GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(self.teleportedGeo.count)", SecureLogger.info("GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(self.teleportedGeo.count)", category: .session)
category: SecureLogger.session, level: .info)
} }
} }
} }
@@ -1625,7 +1617,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} }
// Cache nickname from tag if present // Cache nickname from tag if present
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 { if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
let nick = nickTag[1] let nick = nickTag[1].trimmingCharacters(in: .whitespacesAndNewlines)
self.geoNicknames[event.pubkey.lowercased()] = nick self.geoNicknames[event.pubkey.lowercased()] = nick
} }
// If this pubkey is blocked, skip mapping, participants, and timeline // If this pubkey is blocked, skip mapping, participants, and timeline
@@ -1647,7 +1639,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return return
} }
let timestamp = Date(timeIntervalSince1970: TimeInterval(event.created_at)) // Clamp future timestamps
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let timestamp = min(rawTs, Date())
let mentions = self.parseMentions(from: content) let mentions = self.parseMentions(from: content)
let msg = BitchatMessage( let msg = BitchatMessage(
id: event.id, id: event.id,
@@ -1676,8 +1670,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// pared back logging: subscribe debug only // pared back logging: subscribe debug only
// Log GeoDM subscribe only when Tor is ready to avoid early noise // Log GeoDM subscribe only when Tor is ready to avoid early noise
if TorManager.shared.isReady { if TorManager.shared.isReady {
SecureLogger.log("GeoDM: subscribing DMs pub=\(id.publicKeyHex.prefix(8))… sub=\(dmSub)", SecureLogger.debug("GeoDM: subscribing DMs pub=\(id.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session)
category: SecureLogger.session, level: .debug)
} }
let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)) let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds))
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
@@ -1687,12 +1680,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
self.recordProcessedEvent(giftWrap.id) self.recordProcessedEvent(giftWrap.id)
// Decrypt with per-geohash identity // Decrypt with per-geohash identity
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id) else { guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id) else {
SecureLogger.log("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))", SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))", category: .session)
category: SecureLogger.session, level: .warning)
return return
} }
SecureLogger.log("GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...", SecureLogger.debug("GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...", category: .session)
category: SecureLogger.session, level: .debug)
guard content.hasPrefix("bitchat1:") else { return } guard content.hasPrefix("bitchat1:") else { return }
guard let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))), guard let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
let packet = BitchatPacket.from(packetData) else { return } let packet = BitchatPacket.from(packetData) else { return }
@@ -1705,8 +1696,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
case .privateMessage: case .privateMessage:
guard let pm = PrivateMessagePacket.decode(from: noisePayload.data) else { return } guard let pm = PrivateMessagePacket.decode(from: noisePayload.data) else { return }
let messageId = pm.messageID let messageId = pm.messageID
SecureLogger.log("GeoDM: recv PM <- sender=\(senderPubkey.prefix(8))… mid=\(messageId.prefix(8))", SecureLogger.info("GeoDM: recv PM <- sender=\(senderPubkey.prefix(8))… mid=\(messageId.prefix(8))", category: .session)
category: SecureLogger.session, level: .info)
// Send delivery ACK immediately (even if duplicate), once per messageID // Send delivery ACK immediately (even if duplicate), once per messageID
if !self.sentGeoDeliveryAcks.contains(messageId) { if !self.sentGeoDeliveryAcks.contains(messageId) {
let nostrTransport = NostrTransport() let nostrTransport = NostrTransport()
@@ -1769,11 +1759,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if let idx = self.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) { if let idx = self.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) {
self.privateChats[convKey]?[idx].deliveryStatus = .delivered(to: self.displayNameForNostrPubkey(senderPubkey), at: Date()) self.privateChats[convKey]?[idx].deliveryStatus = .delivered(to: self.displayNameForNostrPubkey(senderPubkey), at: Date())
self.objectWillChange.send() self.objectWillChange.send()
SecureLogger.log("GeoDM: recv DELIVERED for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))", SecureLogger.info("GeoDM: recv DELIVERED for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))", category: .session)
category: SecureLogger.session, level: .info)
} else { } else {
SecureLogger.log("GeoDM: delivered ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", SecureLogger.warning("GeoDM: delivered ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session)
category: SecureLogger.session, level: .warning)
} }
} }
case .readReceipt: case .readReceipt:
@@ -1781,11 +1769,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if let idx = self.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) { if let idx = self.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) {
self.privateChats[convKey]?[idx].deliveryStatus = .read(by: self.displayNameForNostrPubkey(senderPubkey), at: Date()) self.privateChats[convKey]?[idx].deliveryStatus = .read(by: self.displayNameForNostrPubkey(senderPubkey), at: Date())
self.objectWillChange.send() self.objectWillChange.send()
SecureLogger.log("GeoDM: recv READ for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))", SecureLogger.info("GeoDM: recv READ for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))", category: .session)
category: SecureLogger.session, level: .info)
} else { } else {
SecureLogger.log("GeoDM: read ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", SecureLogger.warning("GeoDM: read ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session)
category: SecureLogger.session, level: .warning)
} }
} }
default: default:
@@ -2019,7 +2005,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let senderSuffix = String(event.pubkey.suffix(4)) let senderSuffix = String(event.pubkey.suffix(4))
let nick = self.geoNicknames[event.pubkey.lowercased()] let nick = self.geoNicknames[event.pubkey.lowercased()]
let senderName = (nick?.isEmpty == false ? nick! : "anon") + "#" + senderSuffix let senderName = (nick?.isEmpty == false ? nick! : "anon") + "#" + senderSuffix
let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at)) // Clamp future timestamps
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let ts = min(rawTs, Date())
let mentions = self.parseMentions(from: content) let mentions = self.parseMentions(from: content)
let msg = BitchatMessage( let msg = BitchatMessage(
id: event.id, id: event.id,
@@ -2152,8 +2140,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} }
return return
} }
SecureLogger.log("GeoDM: local send mid=\(messageID.prefix(8))… to=\(recipientHex.prefix(8))… conv=\(peerID)", SecureLogger.debug("GeoDM: local send mid=\(messageID.prefix(8))… to=\(recipientHex.prefix(8))… conv=\(peerID)", category: .session)
category: SecureLogger.session, level: .debug)
let nostrTransport = NostrTransport() let nostrTransport = NostrTransport()
nostrTransport.senderPeerID = meshService.myPeerID nostrTransport.senderPeerID = meshService.myPeerID
nostrTransport.sendPrivateMessageGeohash(content: content, toRecipientHex: recipientHex, from: id, messageID: messageID) nostrTransport.sendPrivateMessageGeohash(content: content, toRecipientHex: recipientHex, from: id, messageID: messageID)
@@ -2473,16 +2460,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// If any temp peer ID had unread messages, mark the consolidated peer as unread // If any temp peer ID had unread messages, mark the consolidated peer as unread
if hadUnreadTemp { if hadUnreadTemp {
unreadPrivateMessages.insert(peerID) unreadPrivateMessages.insert(peerID)
SecureLogger.log("📬 Transferred unread status from temp peer IDs to \(peerID)", SecureLogger.debug("📬 Transferred unread status from temp peer IDs to \(peerID)", category: .session)
category: SecureLogger.session, level: .debug)
} }
if consolidatedCount > 0 { if consolidatedCount > 0 {
// Sort by timestamp // Sort by timestamp
privateChats[peerID]?.sort { $0.timestamp < $1.timestamp } privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
SecureLogger.log("📥 Consolidated \(consolidatedCount) Nostr messages from temporary peer IDs to \(peerNickname)", SecureLogger.info("📥 Consolidated \(consolidatedCount) Nostr messages from temporary peer IDs to \(peerNickname)", category: .session)
category: SecureLogger.session, level: .info)
} }
} }
@@ -2496,8 +2481,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
break break
} }
} else { } else {
SecureLogger.log("GeoDM: skipping mesh handshake for virtual peerID=\(peerID)", SecureLogger.debug("GeoDM: skipping mesh handshake for virtual peerID=\(peerID)", category: .session)
category: SecureLogger.session, level: .debug)
} }
// Delegate to private chat manager but add already-acked messages first // Delegate to private chat manager but add already-acked messages first
@@ -2577,8 +2561,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
@objc private func handleNostrReadReceipt(_ notification: Notification) { @objc private func handleNostrReadReceipt(_ notification: Notification) {
guard let receipt = notification.userInfo?["receipt"] as? ReadReceipt else { return } guard let receipt = notification.userInfo?["receipt"] as? ReadReceipt else { return }
SecureLogger.log("📖 Handling read receipt for message \(receipt.originalMessageID) from Nostr", SecureLogger.info("📖 Handling read receipt for message \(receipt.originalMessageID) from Nostr", category: .session)
category: SecureLogger.session, level: .info)
// Process the read receipt through the same flow as Bluetooth read receipts // Process the read receipt through the same flow as Bluetooth read receipts
didReceiveReadReceipt(receipt) didReceiveReadReceipt(receipt)
@@ -2603,8 +2586,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// If we have a private chat open with the old peer ID, update it to the new one // If we have a private chat open with the old peer ID, update it to the new one
if selectedPrivateChatPeer == oldPeerID { if selectedPrivateChatPeer == oldPeerID {
SecureLogger.log("📱 Updating private chat peer ID due to key change: \(oldPeerID) -> \(newPeerID)", SecureLogger.info("📱 Updating private chat peer ID due to key change: \(oldPeerID) -> \(newPeerID)", category: .session)
category: SecureLogger.session, level: .info)
// Transfer private chat messages to new peer ID // Transfer private chat messages to new peer ID
if let messages = privateChats[oldPeerID] { if let messages = privateChats[oldPeerID] {
@@ -2635,8 +2617,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} else { } else {
// Even if the chat isn't open, migrate any existing private chat data // Even if the chat isn't open, migrate any existing private chat data
if let messages = privateChats[oldPeerID] { if let messages = privateChats[oldPeerID] {
SecureLogger.log("📱 Migrating private chat messages from \(oldPeerID) to \(newPeerID)", SecureLogger.debug("📱 Migrating private chat messages from \(oldPeerID) to \(newPeerID)", category: .session)
category: SecureLogger.session, level: .debug)
var chats = privateChats var chats = privateChats
chats[newPeerID] = messages chats[newPeerID] = messages
chats.removeValue(forKey: oldPeerID) chats.removeValue(forKey: oldPeerID)
@@ -2742,7 +2723,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
messageRouter.sendPrivate(screenshotMessage, to: peerID, recipientNickname: peerNickname, messageID: UUID().uuidString) messageRouter.sendPrivate(screenshotMessage, to: peerID, recipientNickname: peerNickname, messageID: UUID().uuidString)
default: default:
// Don't send screenshot notification if no session exists // Don't send screenshot notification if no session exists
SecureLogger.log("Skipping screenshot notification to \(peerID) - no established session", category: SecureLogger.security, level: .debug) SecureLogger.debug("Skipping screenshot notification to \(peerID) - no established session", category: .security)
} }
} }
@@ -2783,14 +2764,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
) )
let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5) let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
if targetRelays.isEmpty { if targetRelays.isEmpty {
SecureLogger.log("Geo: no geohash relays available for \(ch.geohash); not sending", category: SecureLogger.session, level: .warning) SecureLogger.warning("Geo: no geohash relays available for \(ch.geohash); not sending", category: .session)
} else { } else {
NostrRelayManager.shared.sendEvent(event, to: targetRelays) NostrRelayManager.shared.sendEvent(event, to: targetRelays)
} }
// Track ourselves as active participant // Track ourselves as active participant
self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex) self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
} catch { } catch {
SecureLogger.log("❌ Failed to send geohash screenshot message: \(error)", category: SecureLogger.session, level: .error) SecureLogger.error("❌ Failed to send geohash screenshot message: \(error)", category: .session)
self.addSystemMessage("failed to send to location channel") self.addSystemMessage("failed to send to location channel")
} }
} }
@@ -2848,8 +2829,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Search for the current peer ID with the same nickname // Search for the current peer ID with the same nickname
for (currentPeerID, currentNickname) in meshService.getPeerNicknames() { for (currentPeerID, currentNickname) in meshService.getPeerNicknames() {
if currentNickname == peerNickname { if currentNickname == peerNickname {
SecureLogger.log("📖 Resolved updated peer ID for read receipt: \(peerID) -> \(currentPeerID)", SecureLogger.info("📖 Resolved updated peer ID for read receipt: \(peerID) -> \(currentPeerID)", category: .session)
category: SecureLogger.session, level: .info)
actualPeerID = currentPeerID actualPeerID = currentPeerID
break break
} }
@@ -2877,8 +2857,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let messages = privateChats[peerID] ?? [] let messages = privateChats[peerID] ?? []
for message in messages where message.senderPeerID == peerID && !message.isRelay { for message in messages where message.senderPeerID == peerID && !message.isRelay {
if !sentReadReceipts.contains(message.id) { if !sentReadReceipts.contains(message.id) {
SecureLogger.log("GeoDM: sending READ for mid=\(message.id.prefix(8))… to=\(recipientHex.prefix(8))", SecureLogger.debug("GeoDM: sending READ for mid=\(message.id.prefix(8))… to=\(recipientHex.prefix(8))", category: .session)
category: SecureLogger.session, level: .debug)
let nostrTransport = NostrTransport() let nostrTransport = NostrTransport()
nostrTransport.senderPeerID = meshService.myPeerID nostrTransport.senderPeerID = meshService.myPeerID
nostrTransport.sendReadReceiptGeohash(message.id, toRecipientHex: recipientHex, from: id) nostrTransport.sendReadReceiptGeohash(message.id, toRecipientHex: recipientHex, from: id)
@@ -2986,6 +2965,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// When in a geohash channel, allow resolving by geohash participant nickname // When in a geohash channel, allow resolving by geohash participant nickname
switch LocationChannelManager.shared.selectedChannel { switch LocationChannelManager.shared.selectedChannel {
case .location: case .location:
// If a disambiguation suffix is present (e.g., "name#abcd"), try exact displayName match first
if nickname.contains("#") {
if let person = visibleGeohashPeople().first(where: { $0.displayName == nickname }) {
let convKey = "nostr_" + String(person.id.prefix(TransportConfig.nostrConvKeyPrefixLength))
nostrKeyMapping[convKey] = person.id
return convKey
}
}
let base: String = { let base: String = {
if let hashIndex = nickname.firstIndex(of: "#") { return String(nickname[..<hashIndex]) } if let hashIndex = nickname.firstIndex(of: "#") { return String(nickname[..<hashIndex]) }
return nickname return nickname
@@ -4322,14 +4309,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
verifiedFingerprints = SecureIdentityStateManager.shared.getVerifiedFingerprints() verifiedFingerprints = SecureIdentityStateManager.shared.getVerifiedFingerprints()
// Log snapshot for debugging persistence // Log snapshot for debugging persistence
let sample = Array(verifiedFingerprints.prefix(TransportConfig.uiFingerprintSampleCount)).map { $0.prefix(8) }.joined(separator: ", ") let sample = Array(verifiedFingerprints.prefix(TransportConfig.uiFingerprintSampleCount)).map { $0.prefix(8) }.joined(separator: ", ")
SecureLogger.log("🔐 Verified loaded: \(verifiedFingerprints.count) [\(sample)]", category: SecureLogger.security, level: .info) SecureLogger.info("🔐 Verified loaded: \(verifiedFingerprints.count) [\(sample)]", category: .security)
// Also log any offline favorites and whether we consider them verified // Also log any offline favorites and whether we consider them verified
let offlineFavorites = unifiedPeerService.favorites.filter { !$0.isConnected } let offlineFavorites = unifiedPeerService.favorites.filter { !$0.isConnected }
for fav in offlineFavorites { for fav in offlineFavorites {
let fp = unifiedPeerService.getFingerprint(for: fav.id) let fp = unifiedPeerService.getFingerprint(for: fav.id)
let isVer = fp.flatMap { verifiedFingerprints.contains($0) } ?? false let isVer = fp.flatMap { verifiedFingerprints.contains($0) } ?? false
let fpShort = fp?.prefix(8) ?? "nil" let fpShort = fp?.prefix(8) ?? "nil"
SecureLogger.log("⭐️ Favorite offline: \(fav.nickname) fp=\(fpShort) verified=\(isVer)", category: SecureLogger.security, level: .info) SecureLogger.info("⭐️ Favorite offline: \(fav.nickname) fp=\(fpShort) verified=\(isVer)", category: .security)
} }
// Invalidate cached encryption statuses so offline favorites can show verified badges immediately // Invalidate cached encryption statuses so offline favorites can show verified badges immediately
invalidateEncryptionCache() invalidateEncryptionCache()
@@ -4345,7 +4332,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
DispatchQueue.main.async { DispatchQueue.main.async {
guard let self = self else { return } guard let self = self else { return }
SecureLogger.log("🔐 Authenticated: \(peerID)", category: SecureLogger.security, level: .debug) SecureLogger.debug("🔐 Authenticated: \(peerID)", category: .security)
// Update encryption status // Update encryption status
if self.verifiedFingerprints.contains(fingerprint) { if self.verifiedFingerprints.contains(fingerprint) {
@@ -4364,8 +4351,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let keyData = self.meshService.getNoiseService().getPeerPublicKeyData(peerID) { let keyData = self.meshService.getNoiseService().getPeerPublicKeyData(peerID) {
let stable = keyData.hexEncodedString() let stable = keyData.hexEncodedString()
self.shortIDToNoiseKey[peerID] = stable self.shortIDToNoiseKey[peerID] = stable
SecureLogger.log("🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stable.prefix(8))", SecureLogger.debug("🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stable.prefix(8))", category: .session)
category: SecureLogger.session, level: .debug)
} }
// If a QR verification is pending but not sent yet, send it now that session is authenticated // If a QR verification is pending but not sent yet, send it now that session is authenticated
@@ -4373,7 +4359,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
self.meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: pending.noiseKeyHex, nonceA: pending.nonceA) self.meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: pending.noiseKeyHex, nonceA: pending.nonceA)
pending.sent = true pending.sent = true
self.pendingQRVerifications[peerID] = pending self.pendingQRVerifications[peerID] = pending
SecureLogger.log("📤 Sent deferred verify challenge to \(peerID) after handshake", category: SecureLogger.security, level: .debug) SecureLogger.debug("📤 Sent deferred verify challenge to \(peerID) after handshake", category: .security)
} }
// Schedule UI update // Schedule UI update
@@ -4519,7 +4505,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
pendingQRVerifications.removeValue(forKey: peerID) pendingQRVerifications.removeValue(forKey: peerID)
if let fp = getFingerprint(for: peerID) { if let fp = getFingerprint(for: peerID) {
let short = fp.prefix(8) let short = fp.prefix(8)
SecureLogger.log("🔐 Marking verified fingerprint: \(short)", category: SecureLogger.security, level: .info) SecureLogger.info("🔐 Marking verified fingerprint: \(short)", category: .security)
SecureIdentityStateManager.shared.setVerified(fingerprint: fp, verified: true) SecureIdentityStateManager.shared.setVerified(fingerprint: fp, verified: true)
SecureIdentityStateManager.shared.forceSave() SecureIdentityStateManager.shared.forceSave()
verifiedFingerprints.insert(fp) verifiedFingerprints.insert(fp)
@@ -4605,7 +4591,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// MARK: - Peer Connection Events // MARK: - Peer Connection Events
func didConnectToPeer(_ peerID: String) { func didConnectToPeer(_ peerID: String) {
SecureLogger.log("🤝 Peer connected: \(peerID)", category: SecureLogger.session, level: .debug) SecureLogger.debug("🤝 Peer connected: \(peerID)", category: .session)
// Handle all main actor work async // Handle all main actor work async
Task { @MainActor in Task { @MainActor in
@@ -4614,17 +4600,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Register ephemeral session with identity manager // Register ephemeral session with identity manager
SecureIdentityStateManager.shared.registerEphemeralSession(peerID: peerID) SecureIdentityStateManager.shared.registerEphemeralSession(peerID: peerID)
// Check if we favorite this peer and resend notification on reconnect // Intentionally do not resend favorites on reconnect.
// This ensures Nostr key mapping is maintained across reconnections // We only send our npub when a favorite is toggled on, or if our npub changes.
if let peer = unifiedPeerService.getPeer(by: peerID),
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: peer.noisePublicKey),
favoriteStatus.isFavorite {
// Resend favorite notification with our Nostr key after a short delay
try? await Task.sleep(nanoseconds: TransportConfig.uiAsyncMediumSleepNs) // 0.5 seconds
meshService.sendFavoriteNotification(to: peerID, isFavorite: true)
SecureLogger.log("📤 Resent favorite notification to reconnected peer \(peerID)",
category: SecureLogger.session, level: .debug)
}
// Force UI refresh // Force UI refresh
objectWillChange.send() objectWillChange.send()
@@ -4643,7 +4620,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} }
func didDisconnectFromPeer(_ peerID: String) { func didDisconnectFromPeer(_ peerID: String) {
SecureLogger.log("👋 Peer disconnected: \(peerID)", category: SecureLogger.session, level: .debug) SecureLogger.debug("👋 Peer disconnected: \(peerID)", category: .session)
// Remove ephemeral session from identity manager // Remove ephemeral session from identity manager
SecureIdentityStateManager.shared.removeEphemeralSession(peerID: peerID) SecureIdentityStateManager.shared.removeEphemeralSession(peerID: peerID)
@@ -4738,8 +4715,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
self.lastNetworkNotificationTime = Date() self.lastNetworkNotificationTime = Date()
self.recentlySeenPeers = currentPeerSet self.recentlySeenPeers = currentPeerSet
NotificationService.shared.sendNetworkAvailableNotification(peerCount: meshPeers.count) NotificationService.shared.sendNetworkAvailableNotification(peerCount: meshPeers.count)
SecureLogger.log("👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers", SecureLogger.info("👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers", category: .session)
category: SecureLogger.session, level: .info)
} }
} else { } else {
// No peers immediately reset to allow next rising-edge to notify // No peers immediately reset to allow next rising-edge to notify
@@ -4749,7 +4725,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
self.networkResetTimer?.invalidate() self.networkResetTimer?.invalidate()
self.networkResetTimer = nil self.networkResetTimer = nil
} }
SecureLogger.log("⏳ Mesh empty — reset network notification state", category: SecureLogger.session, level: .debug) SecureLogger.debug("⏳ Mesh empty — reset network notification state", category: .session)
} }
// Register ephemeral sessions for all connected peers // Register ephemeral sessions for all connected peers
@@ -4811,8 +4787,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} }
if !idsToRemove.isEmpty { if !idsToRemove.isEmpty {
SecureLogger.log("🧹 Cleaned up \(idsToRemove.count) stale unread peer IDs", SecureLogger.debug("🧹 Cleaned up \(idsToRemove.count) stale unread peer IDs", category: .session)
category: SecureLogger.session, level: .debug)
} }
} }
@@ -4842,8 +4817,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let removedCount = oldCount - sentReadReceipts.count let removedCount = oldCount - sentReadReceipts.count
if removedCount > 0 { if removedCount > 0 {
SecureLogger.log("🧹 Cleaned up \(removedCount) old read receipts", SecureLogger.debug("🧹 Cleaned up \(removedCount) old read receipts", category: .session)
category: SecureLogger.session, level: .debug)
} }
} }
@@ -4891,7 +4865,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
nostrPublicKey = data.hexEncodedString() nostrPublicKey = data.hexEncodedString()
} }
} catch { } catch {
SecureLogger.log("Failed to decode Nostr npub: \(error)", category: SecureLogger.session, level: .error) SecureLogger.error("Failed to decode Nostr npub: \(error)", category: .session)
} }
} }
@@ -4976,6 +4950,26 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
messages.append(systemMessage) messages.append(systemMessage)
} }
/// Add a system message to the mesh timeline only (never geohash).
/// If mesh is currently active, also append to the visible `messages`.
@MainActor
private func addMeshOnlySystemMessage(_ content: String) {
let systemMessage = BitchatMessage(
sender: "system",
content: content,
timestamp: Date(),
isRelay: false
)
// Persist to mesh timeline
meshTimeline.append(systemMessage)
trimMeshTimelineIfNeeded()
// Only show inline if mesh is the active channel
if case .mesh = activeChannel {
messages.append(systemMessage)
}
objectWillChange.send()
}
/// Public helper to add a system message to the public chat timeline. /// Public helper to add a system message to the public chat timeline.
/// Also persists the message into the active channel's backing store so it survives timeline rebinds. /// Also persists the message into the active channel's backing store so it survives timeline rebinds.
@MainActor @MainActor
@@ -5032,7 +5026,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
NostrRelayManager.shared.sendEvent(event, to: targetRelays) NostrRelayManager.shared.sendEvent(event, to: targetRelays)
} }
} catch { } catch {
SecureLogger.log("❌ Failed to send geohash raw message: \(error)", category: SecureLogger.session, level: .error) SecureLogger.error("❌ Failed to send geohash raw message: \(error)", category: .session)
} }
} }
return return
@@ -5048,12 +5042,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor @MainActor
private func setupNostrMessageHandling() { private func setupNostrMessageHandling() {
guard let currentIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { guard let currentIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else {
SecureLogger.log("⚠️ No Nostr identity available for message handling", category: SecureLogger.session, level: .warning) SecureLogger.warning("⚠️ No Nostr identity available for message handling", category: .session)
return return
} }
SecureLogger.log("🔑 Setting up Nostr subscription for pubkey: \(currentIdentity.publicKeyHex.prefix(16))...", SecureLogger.debug("🔑 Setting up Nostr subscription for pubkey: \(currentIdentity.publicKeyHex.prefix(16))...", category: .session)
category: SecureLogger.session, level: .debug)
// Subscribe to Nostr messages // Subscribe to Nostr messages
let filter = NostrFilter.giftWrapsFor( let filter = NostrFilter.giftWrapsFor(
@@ -5093,19 +5086,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Expect embedded BitChat packet content // Expect embedded BitChat packet content
guard content.hasPrefix("bitchat1:") else { guard content.hasPrefix("bitchat1:") else {
SecureLogger.log("Ignoring non-embedded Nostr DM content", category: SecureLogger.session, level: .debug) SecureLogger.debug("Ignoring non-embedded Nostr DM content", category: .session)
return return
} }
guard let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))), guard let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
let packet = BitchatPacket.from(packetData) else { let packet = BitchatPacket.from(packetData) else {
SecureLogger.log("Failed to decode embedded BitChat packet from Nostr DM", category: SecureLogger.session, level: .error) SecureLogger.error("Failed to decode embedded BitChat packet from Nostr DM", category: .session)
return return
} }
// Only process typed noiseEncrypted envelope for private messages/receipts // Only process typed noiseEncrypted envelope for private messages/receipts
guard packet.type == MessageType.noiseEncrypted.rawValue else { guard packet.type == MessageType.noiseEncrypted.rawValue else {
SecureLogger.log("Unsupported embedded packet type: \(packet.type)", category: SecureLogger.session, level: .warning) SecureLogger.warning("Unsupported embedded packet type: \(packet.type)", category: .session)
return return
} }
@@ -5119,7 +5112,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Parse plaintext typed payload // Parse plaintext typed payload
guard let noisePayload = NoisePayload.decode(packet.payload) else { guard let noisePayload = NoisePayload.decode(packet.payload) else {
SecureLogger.log("Failed to parse embedded NoisePayload", category: SecureLogger.session, level: .error) SecureLogger.error("Failed to parse embedded NoisePayload", category: .session)
return return
} }
@@ -5215,14 +5208,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Send delivery ack via Nostr embedded // Send delivery ack via Nostr embedded
if !wasReadBefore { if !wasReadBefore {
if let key = actualSenderNoiseKey { if let key = actualSenderNoiseKey {
SecureLogger.log("Sending DELIVERED ack for \(messageId.prefix(8))… via router", category: SecureLogger.session, level: .debug) SecureLogger.debug("Sending DELIVERED ack for \(messageId.prefix(8))… via router", category: .session)
messageRouter.sendDeliveryAck(messageId, to: key.hexEncodedString()) messageRouter.sendDeliveryAck(messageId, to: key.hexEncodedString())
} else if let id = try? NostrIdentityBridge.getCurrentNostrIdentity() { } else if let id = try? NostrIdentityBridge.getCurrentNostrIdentity() {
// Fallback: no Noise mapping yet send directly to sender's Nostr pubkey // Fallback: no Noise mapping yet send directly to sender's Nostr pubkey
let nt = NostrTransport() let nt = NostrTransport()
nt.senderPeerID = meshService.myPeerID nt.senderPeerID = meshService.myPeerID
nt.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubkey, from: id) nt.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubkey, from: id)
SecureLogger.log("Sent DELIVERED ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(messageId.prefix(8))", category: SecureLogger.session, level: .debug) SecureLogger.debug("Sent DELIVERED ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(messageId.prefix(8))", category: .session)
} }
} }
@@ -5237,7 +5230,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if !sentReadReceipts.contains(messageId) { if !sentReadReceipts.contains(messageId) {
if let key = actualSenderNoiseKey { if let key = actualSenderNoiseKey {
let receipt = ReadReceipt(originalMessageID: messageId, readerID: meshService.myPeerID, readerNickname: nickname) let receipt = ReadReceipt(originalMessageID: messageId, readerID: meshService.myPeerID, readerNickname: nickname)
SecureLogger.log("Viewing chat; sending READ ack for \(messageId.prefix(8))… via router", category: SecureLogger.session, level: .debug) SecureLogger.debug("Viewing chat; sending READ ack for \(messageId.prefix(8))… via router", category: .session)
messageRouter.sendReadReceipt(receipt, to: key.hexEncodedString()) messageRouter.sendReadReceipt(receipt, to: key.hexEncodedString())
sentReadReceipts.insert(messageId) sentReadReceipts.insert(messageId)
} else if let id = try? NostrIdentityBridge.getCurrentNostrIdentity() { } else if let id = try? NostrIdentityBridge.getCurrentNostrIdentity() {
@@ -5245,7 +5238,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
nt.senderPeerID = meshService.myPeerID nt.senderPeerID = meshService.myPeerID
nt.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubkey, from: id) nt.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubkey, from: id)
sentReadReceipts.insert(messageId) sentReadReceipts.insert(messageId)
SecureLogger.log("Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(messageId.prefix(8))", category: SecureLogger.session, level: .debug) SecureLogger.debug("Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(messageId.prefix(8))", category: .session)
} }
} }
} else { } else {
@@ -5290,7 +5283,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} }
} catch { } catch {
SecureLogger.log("Failed to decrypt Nostr message: \(error)", category: SecureLogger.session, level: .error) SecureLogger.error("Failed to decrypt Nostr message: \(error)", category: .session)
} }
} }
@@ -5311,7 +5304,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Parse ACK format: "ACK:TYPE:MESSAGE_ID" // Parse ACK format: "ACK:TYPE:MESSAGE_ID"
let parts = content.split(separator: ":", maxSplits: 2) let parts = content.split(separator: ":", maxSplits: 2)
guard parts.count >= 3 else { guard parts.count >= 3 else {
SecureLogger.log("⚠️ Invalid ACK format: \(content)", category: SecureLogger.session, level: .warning) SecureLogger.warning("⚠️ Invalid ACK format: \(content)", category: .session)
return return
} }
@@ -5326,8 +5319,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} }
processedNostrAcks.insert(ackKey) processedNostrAcks.insert(ackKey)
SecureLogger.log("📨 Received \(ackType) ACK for message \(messageId.prefix(16))... from \(senderPubkey.prefix(16))...", SecureLogger.debug("📨 Received \(ackType) ACK for message \(messageId.prefix(16))... from \(senderPubkey.prefix(16))...", category: .session)
category: SecureLogger.session, level: .debug)
// Verify the sender has a valid Noise key // Verify the sender has a valid Noise key
guard findNoiseKey(for: senderPubkey) != nil else { guard findNoiseKey(for: senderPubkey) != nil else {
@@ -5346,12 +5338,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
case "READ": case "READ":
privateChats[chatPeerID]?[index].deliveryStatus = .read(by: "recipient", at: Date()) privateChats[chatPeerID]?[index].deliveryStatus = .read(by: "recipient", at: Date())
default: default:
SecureLogger.log("⚠️ Unknown ACK type: \(ackType)", category: SecureLogger.session, level: .warning) SecureLogger.warning("⚠️ Unknown ACK type: \(ackType)", category: .session)
} }
messageFound = true messageFound = true
SecureLogger.log("✅ Updated message \(messageId.prefix(16))... status to \(ackType) in chat \(chatPeerID.prefix(16))...", SecureLogger.info("✅ Updated message \(messageId.prefix(16))... status to \(ackType) in chat \(chatPeerID.prefix(16))...", category: .session)
category: SecureLogger.session, level: .info)
// Don't break - continue to update in all chats where this message exists // Don't break - continue to update in all chats where this message exists
} }
} }
@@ -5359,8 +5350,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if messageFound { if messageFound {
objectWillChange.send() objectWillChange.send()
} else { } else {
SecureLogger.log("⚠️ Could not find message \(messageId) to update status from ACK", SecureLogger.warning("⚠️ Could not find message \(messageId) to update status from ACK", category: .session)
category: SecureLogger.session, level: .warning)
} }
} }
@@ -5386,8 +5376,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
var nostrPubkey: String? = nil var nostrPubkey: String? = nil
if parts.count > 1 { if parts.count > 1 {
nostrPubkey = String(parts[1]) nostrPubkey = String(parts[1])
SecureLogger.log("📝 Received Nostr npub in favorite notification: \(nostrPubkey ?? "none")", SecureLogger.info("📝 Received Nostr npub in favorite notification: \(nostrPubkey ?? "none")", category: .session)
category: SecureLogger.session, level: .info)
} }
// Get the noise public key for this peer // Get the noise public key for this peer
@@ -5405,28 +5394,30 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} }
guard let finalNoiseKey = noiseKey else { guard let finalNoiseKey = noiseKey else {
SecureLogger.log("⚠️ Cannot get Noise key for peer \(peerID)", SecureLogger.warning("⚠️ Cannot get Noise key for peer \(peerID)", category: .session)
category: SecureLogger.session, level: .warning)
return return
} }
// Determine prior state to avoid duplicate system messages on repeated notifications
// Update the favorite relationship let prior = FavoritesPersistenceService.shared.getFavoriteStatus(for: finalNoiseKey)?.theyFavoritedUs ?? false
// Update the favorite relationship (idempotent storage)
FavoritesPersistenceService.shared.updatePeerFavoritedUs( FavoritesPersistenceService.shared.updatePeerFavoritedUs(
peerNoisePublicKey: finalNoiseKey, peerNoisePublicKey: finalNoiseKey,
favorited: isFavorite, favorited: isFavorite,
peerNickname: senderNickname, peerNickname: senderNickname,
peerNostrPublicKey: nostrPubkey peerNostrPublicKey: nostrPubkey
) )
// If they favorited us and provided their Nostr key, ensure it's stored // If they favorited us and provided their Nostr key, ensure it's stored (log only)
if isFavorite && nostrPubkey != nil { if isFavorite && nostrPubkey != nil {
SecureLogger.log("💾 Storing Nostr key association for \(senderNickname): \(nostrPubkey!.prefix(16))...", SecureLogger.info("💾 Storing Nostr key association for \(senderNickname): \(nostrPubkey!.prefix(16))...", category: .session)
category: SecureLogger.session, level: .info) }
// Only show a system message when the state changes, and only in mesh
if prior != isFavorite {
let action = isFavorite ? "favorited" : "unfavorited"
addMeshOnlySystemMessage("\(senderNickname) \(action) you")
} }
// Show system message
let action = isFavorite ? "favorited" : "unfavorited"
addSystemMessage("\(senderNickname) \(action) you")
} }
@MainActor @MainActor
@@ -5532,8 +5523,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Not notifying for old message // Not notifying for old message
} }
SecureLogger.log("📬 Stored Nostr message from unknown sender \(finalSenderNickname) in temporary peer \(tempPeerID)", SecureLogger.info("📬 Stored Nostr message from unknown sender \(finalSenderNickname) in temporary peer \(tempPeerID)", category: .session)
category: SecureLogger.session, level: .info)
} }
@MainActor @MainActor
@@ -5545,16 +5535,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} else { } else {
// Try to convert hex to npub // Try to convert hex to npub
guard let pubkeyData = Data(hexString: nostrPubkey) else { guard let pubkeyData = Data(hexString: nostrPubkey) else {
SecureLogger.log("⚠️ Invalid hex public key format: \(nostrPubkey.prefix(16))...", SecureLogger.warning("⚠️ Invalid hex public key format: \(nostrPubkey.prefix(16))...", category: .session)
category: SecureLogger.session, level: .warning)
return nil return nil
} }
do { do {
npubToMatch = try Bech32.encode(hrp: "npub", data: pubkeyData) npubToMatch = try Bech32.encode(hrp: "npub", data: pubkeyData)
} catch { } catch {
SecureLogger.log("⚠️ Failed to convert hex to npub: \(error)", SecureLogger.warning("⚠️ Failed to convert hex to npub: \(error)", category: .session)
category: SecureLogger.session, level: .warning)
return nil return nil
} }
} }
@@ -5564,22 +5552,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if let storedNostrKey = relationship.peerNostrPublicKey { if let storedNostrKey = relationship.peerNostrPublicKey {
// Compare npub format // Compare npub format
if storedNostrKey == npubToMatch { if storedNostrKey == npubToMatch {
// SecureLogger.log(" Found Noise key for Nostr sender (npub match)", // SecureLogger.debug(" Found Noise key for Nostr sender (npub match)", category: .session)
// category: SecureLogger.session, level: .debug)
return noiseKey return noiseKey
} }
// Also try hex comparison if stored value is hex // Also try hex comparison if stored value is hex
if !storedNostrKey.hasPrefix("npub") && storedNostrKey == nostrPubkey { if !storedNostrKey.hasPrefix("npub") && storedNostrKey == nostrPubkey {
SecureLogger.log("✅ Found Noise key for Nostr sender (hex match)", SecureLogger.debug("✅ Found Noise key for Nostr sender (hex match)", category: .session)
category: SecureLogger.session, level: .debug)
return noiseKey return noiseKey
} }
} }
} }
SecureLogger.log("⚠️ No matching Noise key found for Nostr pubkey: \(nostrPubkey.prefix(16))... (tried npub: \(npubToMatch.prefix(16))...)", SecureLogger.debug("⚠️ No matching Noise key found for Nostr pubkey: \(nostrPubkey.prefix(16))... (tried npub: \(npubToMatch.prefix(16))...)", category: .session)
category: SecureLogger.session, level: .debug)
return nil return nil
} }
@@ -5607,13 +5592,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Try mesh first for connected peers // Try mesh first for connected peers
if meshService.isPeerConnected(peerID) { if meshService.isPeerConnected(peerID) {
messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite) messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
SecureLogger.log("📤 Sent favorite notification via BLE to \(peerID)", category: SecureLogger.session, level: .debug) SecureLogger.debug("📤 Sent favorite notification via BLE to \(peerID)", category: .session)
} else if let key = noiseKey { } else if let key = noiseKey {
// Send via Nostr for offline peers (using router) // Send via Nostr for offline peers (using router)
let recipientPeerID = key.hexEncodedString() let recipientPeerID = key.hexEncodedString()
messageRouter.sendFavoriteNotification(to: recipientPeerID, isFavorite: isFavorite) messageRouter.sendFavoriteNotification(to: recipientPeerID, isFavorite: isFavorite)
} else { } else {
SecureLogger.log("⚠️ Cannot send favorite notification - peer not connected and no Nostr pubkey", category: SecureLogger.session, level: .warning) SecureLogger.warning("⚠️ Cannot send favorite notification - peer not connected and no Nostr pubkey", category: .session)
} }
} }
@@ -5703,12 +5688,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
oldPeerIDsToRemove.append(oldPeerID) oldPeerIDsToRemove.append(oldPeerID)
} else { } else {
// Keep old messages in original location but don't show in UI // Keep old messages in original location but don't show in UI
SecureLogger.log("📦 Partially migrating \(recentMessages.count) of \(messages.count) messages from \(oldPeerID)", SecureLogger.info("📦 Partially migrating \(recentMessages.count) of \(messages.count) messages from \(oldPeerID)", category: .session)
category: SecureLogger.session, level: .info)
} }
SecureLogger.log("📦 Migrating \(recentMessages.count) recent messages from old peer ID \(oldPeerID) to \(peerID) (fingerprint match)", SecureLogger.info("📦 Migrating \(recentMessages.count) recent messages from old peer ID \(oldPeerID) to \(peerID) (fingerprint match)", category: .session)
category: SecureLogger.session, level: .info)
} else if currentFingerprint == nil || oldFingerprint == nil { } else if currentFingerprint == nil || oldFingerprint == nil {
// Check if this chat contains messages with this sender by nickname // Check if this chat contains messages with this sender by nickname
let isRelevantChat = recentMessages.contains { msg in let isRelevantChat = recentMessages.contains { msg in
@@ -5724,8 +5707,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
oldPeerIDsToRemove.append(oldPeerID) oldPeerIDsToRemove.append(oldPeerID)
} }
SecureLogger.log("📦 Migrating \(recentMessages.count) recent messages from old peer ID \(oldPeerID) to \(peerID) (nickname match)", SecureLogger.warning("📦 Migrating \(recentMessages.count) recent messages from old peer ID \(oldPeerID) to \(peerID) (nickname match)", category: .session)
category: SecureLogger.session, level: .warning)
} }
} }
} }
@@ -5764,8 +5746,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Update selectedPrivateChatPeer if it was pointing to an old ID // Update selectedPrivateChatPeer if it was pointing to an old ID
if needsSelectedUpdate { if needsSelectedUpdate {
selectedPrivateChatPeer = peerID selectedPrivateChatPeer = peerID
SecureLogger.log("📱 Updated selectedPrivateChatPeer from old ID to \(peerID) during migration", SecureLogger.info("📱 Updated selectedPrivateChatPeer from old ID to \(peerID) during migration", category: .session)
category: SecureLogger.session, level: .info)
} }
} }
} }
@@ -5774,11 +5755,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
/// Handle incoming private message /// Handle incoming private message
@MainActor @MainActor
private func handlePrivateMessage(_ message: BitchatMessage) { private func handlePrivateMessage(_ message: BitchatMessage) {
SecureLogger.log("📥 handlePrivateMessage called for message from \(message.sender)", category: SecureLogger.session, level: .debug) SecureLogger.debug("📥 handlePrivateMessage called for message from \(message.sender)", category: .session)
let senderPeerID = message.senderPeerID ?? getPeerIDForNickname(message.sender) let senderPeerID = message.senderPeerID ?? getPeerIDForNickname(message.sender)
guard let peerID = senderPeerID else { guard let peerID = senderPeerID else {
SecureLogger.log("⚠️ Could not get peer ID for sender \(message.sender)", category: SecureLogger.session, level: .warning) SecureLogger.warning("⚠️ Could not get peer ID for sender \(message.sender)", category: .session)
return return
} }
@@ -5818,8 +5799,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Clean up the stable key storage to avoid duplication // Clean up the stable key storage to avoid duplication
privateChats.removeValue(forKey: stableKeyHex) privateChats.removeValue(forKey: stableKeyHex)
SecureLogger.log("📥 Consolidated \(nostrMessages.count) Nostr messages from stable key to ephemeral peer \(peerID)", SecureLogger.info("📥 Consolidated \(nostrMessages.count) Nostr messages from stable key to ephemeral peer \(peerID)", category: .session)
category: SecureLogger.session, level: .info)
} }
} }
} }
@@ -6021,10 +6001,20 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
isBatchingPublic = true isBatchingPublic = true
// Rough chronological order: sort the batch by timestamp before inserting // Rough chronological order: sort the batch by timestamp before inserting
added.sort { $0.timestamp < $1.timestamp } added.sort { $0.timestamp < $1.timestamp }
// Insert late arrivals into approximate position; append recent ones // Channel-aware insertion policy: geohash uses strict ordering; mesh allows small out-of-order appends
let threshold: TimeInterval = {
switch activeChannel {
case .location: return TransportConfig.uiLateInsertThresholdGeo
case .mesh: return TransportConfig.uiLateInsertThreshold
}
}()
let lastTs = messages.last?.timestamp ?? .distantPast let lastTs = messages.last?.timestamp ?? .distantPast
for m in added { for m in added {
if m.timestamp < lastTs.addingTimeInterval(-lateInsertThreshold) { if m.timestamp < lastTs.addingTimeInterval(-threshold) {
let idx = insertionIndexByTimestamp(m.timestamp)
if idx >= messages.count { messages.append(m) } else { messages.insert(m, at: idx) }
} else if threshold == 0 {
// Strict ordering for geohash: always insert by timestamp
let idx = insertionIndexByTimestamp(m.timestamp) let idx = insertionIndexByTimestamp(m.timestamp)
if idx >= messages.count { messages.append(m) } else { messages.insert(m, at: idx) } if idx >= messages.count { messages.append(m) } else { messages.insert(m, at: idx) }
} else { } else {
@@ -6086,8 +6076,7 @@ private func checkForMentions(_ message: BitchatMessage) {
let isMentioned = (message.mentions?.contains { myTokens.contains($0) } ?? false) let isMentioned = (message.mentions?.contains { myTokens.contains($0) } ?? false)
if isMentioned && message.sender != nickname { if isMentioned && message.sender != nickname {
SecureLogger.log("🔔 Mention from \(message.sender)", SecureLogger.info("🔔 Mention from \(message.sender)", category: .session)
category: SecureLogger.session, level: .info)
NotificationService.shared.sendMentionNotification(from: message.sender, message: message.content) NotificationService.shared.sendMentionNotification(from: message.sender, message: message.content)
} }
} }
+13 -1
View File
@@ -444,7 +444,19 @@ struct ContentView: View {
let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let peerID = id.removingPercentEncoding ?? id let peerID = id.removingPercentEncoding ?? id
selectedMessageSenderID = peerID selectedMessageSenderID = peerID
selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID })?.sender // Derive a stable display name from the peerID instead of peeking at the last message,
// which may be a transformed system action (sender == "system").
if peerID.hasPrefix("nostr") {
// For geohash senders, resolve display name via mapping (works for "nostr:" and "nostr_" keys)
selectedMessageSender = viewModel.geohashDisplayName(for: peerID)
} else {
// Mesh sender: use current mesh nickname if available; otherwise fall back to last non-system message
if let name = viewModel.meshService.peerNickname(peerID: peerID) {
selectedMessageSender = name
} else {
selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender
}
}
showMessageActions = true showMessageActions = true
} }
.onOpenURL { url in .onOpenURL { url in
+1 -1
View File
@@ -270,7 +270,7 @@ final class BLEServiceTests: XCTestCase {
// MARK: - Mock Delegate Helper // MARK: - Mock Delegate Helper
private class MockBitchatDelegate: BitchatDelegate { private final class MockBitchatDelegate: BitchatDelegate {
private let messageHandler: (BitchatMessage) -> Void private let messageHandler: (BitchatMessage) -> Void
init(_ handler: @escaping (BitchatMessage) -> Void) { init(_ handler: @escaping (BitchatMessage) -> Void) {
+42
View File
@@ -0,0 +1,42 @@
import XCTest
@testable import bitchat
final class CommandProcessorTests: XCTestCase {
@MainActor
func test_slap_notFoundGrammar() {
let processor = CommandProcessor(chatViewModel: nil, meshService: nil)
let result = processor.process("/slap @system")
switch result {
case .error(let message):
XCTAssertEqual(message, "cannot slap system: not found")
default:
XCTFail("Expected error result")
}
}
@MainActor
func test_hug_notFoundGrammar() {
let processor = CommandProcessor(chatViewModel: nil, meshService: nil)
let result = processor.process("/hug @system")
switch result {
case .error(let message):
XCTAssertEqual(message, "cannot hug system: not found")
default:
XCTFail("Expected error result")
}
}
@MainActor
func test_slap_usageMessage() {
let processor = CommandProcessor(chatViewModel: nil, meshService: nil)
let result = processor.process("/slap")
switch result {
case .error(let message):
XCTAssertEqual(message, "usage: /slap <nickname>")
default:
XCTFail("Expected error result for usage message")
}
}
}
+1 -1
View File
@@ -26,7 +26,7 @@ import CoreBluetooth
/// - `autoFloodEnabled` is disabled by default; Integration tests enable it in `setUp()` to /// - `autoFloodEnabled` is disabled by default; Integration tests enable it in `setUp()` to
/// simulate broadcast propagation across the mesh. E2E tests keep it off and perform explicit /// simulate broadcast propagation across the mesh. E2E tests keep it off and perform explicit
/// relays when needed. /// relays when needed.
class MockBLEService: NSObject { final class MockBLEService: NSObject {
// Enable automatic flooding for public messages in integration tests only // Enable automatic flooding for public messages in integration tests only
static var autoFloodEnabled: Bool = false static var autoFloodEnabled: Bool = false
+1 -1
View File
@@ -10,7 +10,7 @@ import Foundation
import CryptoKit import CryptoKit
@testable import bitchat @testable import bitchat
class TestHelpers { final class TestHelpers {
// MARK: - Key Generation // MARK: - Key Generation
+5
View File
@@ -32,6 +32,8 @@ targets:
CFBundleVersion: $(CURRENT_PROJECT_VERSION) CFBundleVersion: $(CURRENT_PROJECT_VERSION)
NSBluetoothAlwaysUsageDescription: bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users. NSBluetoothAlwaysUsageDescription: bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.
NSBluetoothPeripheralUsageDescription: bitchat uses Bluetooth to discover and connect with other bitchat users nearby. NSBluetoothPeripheralUsageDescription: bitchat uses Bluetooth to discover and connect with other bitchat users nearby.
NSCameraUsageDescription: bitchat uses the camera to scan QR codes to verify peers.
NSLocationWhenInUseUsageDescription: bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.
UIBackgroundModes: UIBackgroundModes:
- bluetooth-central - bluetooth-central
- bluetooth-peripheral - bluetooth-peripheral
@@ -89,6 +91,8 @@ targets:
LSMinimumSystemVersion: $(MACOSX_DEPLOYMENT_TARGET) LSMinimumSystemVersion: $(MACOSX_DEPLOYMENT_TARGET)
NSBluetoothAlwaysUsageDescription: bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users. NSBluetoothAlwaysUsageDescription: bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.
NSBluetoothPeripheralUsageDescription: bitchat uses Bluetooth to discover and connect with other bitchat users nearby. NSBluetoothPeripheralUsageDescription: bitchat uses Bluetooth to discover and connect with other bitchat users nearby.
NSCameraUsageDescription: bitchat uses the camera to scan QR codes to verify peers.
NSLocationWhenInUseUsageDescription: bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.
CFBundleURLTypes: CFBundleURLTypes:
- CFBundleURLSchemes: - CFBundleURLSchemes:
- bitchat - bitchat
@@ -126,6 +130,7 @@ targets:
platform: iOS platform: iOS
sources: sources:
- bitchatShareExtension - bitchatShareExtension
- bitchat/Services/TransportConfig.swift
info: info:
path: bitchatShareExtension/Info.plist path: bitchatShareExtension/Info.plist
properties: properties: