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
The main service managing all Noise operations:
```swift
class NoiseEncryptionService {
final class NoiseEncryptionService {
private let staticIdentityKey: Curve25519.KeyAgreement.PrivateKey
private let sessionManager: NoiseSessionManager
private let channelEncryption = NoiseChannelEncryption()
@@ -47,7 +47,7 @@ class NoiseEncryptionService {
#### NoiseSession
Individual session state for each peer:
```swift
class NoiseSession {
final class NoiseSession {
private var handshakeState: NoiseHandshakeState?
private var sendCipher: NoiseCipherState?
private var receiveCipher: NoiseCipherState?
@@ -58,7 +58,7 @@ class NoiseSession {
#### NoiseSessionManager
Thread-safe session management:
```swift
class NoiseSessionManager {
final class NoiseSessionManager {
private var sessions: [String: NoiseSession] = [:]
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 */; };
0481A35B2E6D9BEF00FC845E /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35A2E6D9BEF00FC845E /* libz.tbd */; };
0481A35D2E6DA18600FC845E /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35C2E6DA18600FC845E /* libz.tbd */; };
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 */; };
0481A3902E734CAE00FC845E /* CommandProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A38F2E734CAE00FC845E /* CommandProcessorTests.swift */; };
0481A3912E734CAE00FC845E /* CommandProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A38F2E734CAE00FC845E /* CommandProcessorTests.swift */; };
048A4BE72E5CCCC300162C4A /* 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 */; };
@@ -76,6 +74,10 @@
049BD3B52E51F319001A566B /* MessageRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3B02E51F319001A566B /* MessageRouter.swift */; };
0AE840940F21AFC07C226636 /* PrivateChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A262EDDC04B7D7B5E31F321 /* PrivateChatE2ETests.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 */; };
1234567890ABCDEFFEDCBA13 /* 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 */; };
EF49C600C1E464710DD6CA29 /* InputValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90CB7A5CD1D1A521CD31F380 /* InputValidator.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 */; };
FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136696FC4436A02D98CE6A77 /* KeychainManager.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>"; };
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>"; };
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>"; };
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; };
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>"; };
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>"; };
@@ -247,6 +250,8 @@
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>"; };
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>"; };
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>"; };
@@ -298,6 +303,7 @@
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>"; };
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>"; };
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>"; };
@@ -471,6 +477,7 @@
32F149C43D1915831B60FE09 /* CompressionUtil.swift */,
90CB7A5CD1D1A521CD31F380 /* InputValidator.swift */,
EE7EFB209C86BBD956B749EC /* SecureLogger.swift */,
F0A1B2C3D4E5F60718293A4A /* OSLog+Categories.swift */,
);
path = Utils;
sourceTree = "<group>";
@@ -538,8 +545,9 @@
C3D98EB3E1B455E321F519F4 /* bitchatTests */ = {
isa = PBXGroup;
children = (
048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */,
D69A18D27F9A565FD6041E12 /* Info.plist */,
0481A38F2E734CAE00FC845E /* CommandProcessorTests.swift */,
048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */,
047502912E547ACC0083520F /* LocationChannelsTests.swift */,
C272F137CE00FC5A96E0CC06 /* NostrProtocolTests.swift */,
980B109CBA72BC996455C62B /* BLEServiceTests.swift */,
@@ -802,6 +810,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
F0A1B2C3D4E5F60718293A4C /* OSLog+Categories.swift in Sources */,
0C0EFA122E6EAAAA00ABCDF0 /* CTorHost.c in Sources */,
0C0EFA172E6EAABB00ABCDF5 /* TorNotifications.swift in Sources */,
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */,
@@ -866,6 +875,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
F0A1B2C3D4E5F60718293A4B /* OSLog+Categories.swift in Sources */,
0C0EFA112E6EAAAA00ABCDEF /* CTorHost.c in Sources */,
0C0EFA162E6EAABB00ABCDF4 /* TorNotifications.swift in Sources */,
048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */,
@@ -934,6 +944,7 @@
047502802E53A0FC0083520F /* FragmentationTests.swift in Sources */,
8F282E9CCA5AE1ECC001D2E4 /* IntegrationTests.swift in Sources */,
047502B12E55E8450083520F /* InputValidatorTests.swift in Sources */,
0481A3912E734CAE00FC845E /* CommandProcessorTests.swift in Sources */,
D727EA273CB214FC32612469 /* MockBluetoothMeshService.swift in Sources */,
047502932E547ACC0083520F /* LocationChannelsTests.swift in Sources */,
048A4C2B2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */,
@@ -957,6 +968,7 @@
047502812E53A0FC0083520F /* FragmentationTests.swift in Sources */,
686441ABC2AF83EE98E6ECF2 /* IntegrationTests.swift in Sources */,
047502B02E55E8450083520F /* InputValidatorTests.swift in Sources */,
0481A3902E734CAE00FC845E /* CommandProcessorTests.swift in Sources */,
8851F08D88C5B1DE7B9F55C6 /* MockBluetoothMeshService.swift in Sources */,
047502922E547ACC0083520F /* LocationChannelsTests.swift in Sources */,
048A4C2C2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */,
@@ -1,111 +1,9 @@
{
"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",
"idiom" : "ios-marketing",
"scale" : "1x",
"idiom" : "universal",
"platform" : "ios",
"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)
class AppDelegate: NSObject, UIApplicationDelegate {
final class AppDelegate: NSObject, UIApplicationDelegate {
weak var chatViewModel: ChatViewModel?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
@@ -175,7 +175,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
#if os(macOS)
import AppKit
class MacAppDelegate: NSObject, NSApplicationDelegate {
final class MacAppDelegate: NSObject, NSApplicationDelegate {
weak var chatViewModel: ChatViewModel?
func applicationWillTerminate(_ notification: Notification) {
@@ -188,7 +188,7 @@ class MacAppDelegate: NSObject, NSApplicationDelegate {
}
#endif
class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
static let shared = NotificationDelegate()
weak var chatViewModel: ChatViewModel?
@@ -96,7 +96,7 @@ import CryptoKit
/// Singleton manager for secure identity state persistence and retrieval.
/// Provides thread-safe access to identity mappings with encryption at rest.
/// All identity data is stored encrypted in the device Keychain for security.
class SecureIdentityStateManager {
final class SecureIdentityStateManager {
static let shared = SecureIdentityStateManager()
private let keychain = KeychainManager.shared
@@ -129,7 +129,7 @@ class SecureIdentityStateManager {
// Try to load from keychain
if let keyData = keychain.getIdentityKey(forKey: encryptionKeyName) {
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
else {
@@ -137,7 +137,7 @@ class SecureIdentityStateManager {
let keyData = loadedKey.withUnsafeBytes { Data($0) }
// Save to keychain
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
@@ -160,7 +160,7 @@ class SecureIdentityStateManager {
cache = try JSONDecoder().decode(IdentityCache.self, from: decryptedData)
} catch {
// 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 saved = keychain.saveIdentityKey(sealedBox.combined!, forKey: cacheKey)
if saved {
SecureLogger.log("Identity cache saved to keychain", category: SecureLogger.security, level: .debug)
SecureLogger.debug("Identity cache saved to keychain", category: .security)
}
} 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) {
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) {
if var identity = self.cache.socialIdentities[fingerprint] {
@@ -519,7 +519,7 @@ class SecureIdentityStateManager {
// MARK: - Cleanup
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) {
self.cache = IdentityCache()
@@ -529,7 +529,7 @@ class SecureIdentityStateManager {
// Delete from keychain
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
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) {
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>
<key>NSBluetoothPeripheralUsageDescription</key>
<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>
<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>
<array>
<string>bluetooth-central</string>
+17 -29
View File
@@ -9,7 +9,7 @@
import Foundation
/// Coordinates Noise handshakes to prevent race conditions and ensure reliable encryption establishment
class NoiseHandshakeCoordinator {
final class NoiseHandshakeCoordinator {
// MARK: - Handshake State
@@ -68,8 +68,7 @@ class NoiseHandshakeCoordinator {
switch state {
case .initiating(_, let lastAttempt):
if Date().timeIntervalSince(lastAttempt) > handshakeTimeout {
SecureLogger.log("Forcing new handshake with \(remotePeerID) - previous stuck in initiating",
category: SecureLogger.handshake, level: .warning)
SecureLogger.warning("Forcing new handshake with \(remotePeerID) - previous stuck in initiating", category: .handshake)
return true
}
default:
@@ -77,8 +76,7 @@ class NoiseHandshakeCoordinator {
}
}
SecureLogger.log("Already in active handshake with \(remotePeerID), state: \(state)",
category: SecureLogger.handshake, level: .debug)
SecureLogger.debug("Already in active handshake with \(remotePeerID), state: \(state)", category: .handshake)
return false
}
@@ -107,8 +105,7 @@ class NoiseHandshakeCoordinator {
handshakeQueue.async(flags: .barrier) {
let attempt = self.getCurrentAttempt(for: peerID) + 1
self.handshakeStates[peerID] = .initiating(attempt: attempt, lastAttempt: Date())
SecureLogger.log("Recording handshake initiation with \(peerID), attempt \(attempt)",
category: SecureLogger.handshake, level: .info)
SecureLogger.info("Recording handshake initiation with \(peerID), attempt \(attempt)", category: .handshake)
}
}
@@ -116,8 +113,7 @@ class NoiseHandshakeCoordinator {
func recordHandshakeResponse(peerID: String) {
handshakeQueue.async(flags: .barrier) {
self.handshakeStates[peerID] = .responding(since: Date())
SecureLogger.log("Recording handshake response to \(peerID)",
category: SecureLogger.handshake, level: .info)
SecureLogger.info("Recording handshake response to \(peerID)", category: .handshake)
}
}
@@ -125,8 +121,7 @@ class NoiseHandshakeCoordinator {
func recordHandshakeSuccess(peerID: String) {
handshakeQueue.async(flags: .barrier) {
self.handshakeStates[peerID] = .established(since: Date())
SecureLogger.log("Handshake successfully established with \(peerID)",
category: SecureLogger.handshake, level: .info)
SecureLogger.info("Handshake successfully established with \(peerID)", category: .handshake)
}
}
@@ -136,8 +131,7 @@ class NoiseHandshakeCoordinator {
let attempts = self.getCurrentAttempt(for: peerID)
let canRetry = attempts < self.maxHandshakeAttempts
self.handshakeStates[peerID] = .failed(reason: reason, canRetry: canRetry, lastAttempt: Date())
SecureLogger.log("Handshake failed with \(peerID): \(reason), canRetry: \(canRetry)",
category: SecureLogger.handshake, level: .warning)
SecureLogger.warning("Handshake failed with \(peerID): \(reason), canRetry: \(canRetry)", category: .handshake)
}
}
@@ -146,8 +140,7 @@ class NoiseHandshakeCoordinator {
return handshakeQueue.sync {
// If we're already established, reject new handshakes
if case .established = handshakeStates[remotePeerID] {
SecureLogger.log("Rejecting handshake from \(remotePeerID) - already established",
category: SecureLogger.handshake, level: .debug)
SecureLogger.debug("Rejecting handshake from \(remotePeerID) - already established", category: .handshake)
return false
}
@@ -157,8 +150,7 @@ class NoiseHandshakeCoordinator {
if role == .initiator {
if case .initiating = handshakeStates[remotePeerID] {
// 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)",
category: SecureLogger.handshake, level: .warning)
SecureLogger.warning("Accepting handshake from \(remotePeerID) despite being initiator (race condition recovery)", category: .handshake)
return true
}
}
@@ -215,8 +207,7 @@ class NoiseHandshakeCoordinator {
func resetHandshakeState(for peerID: String) {
handshakeQueue.async(flags: .barrier) {
self.handshakeStates.removeValue(forKey: peerID)
SecureLogger.log("Reset handshake state for \(peerID)",
category: SecureLogger.handshake, level: .debug)
SecureLogger.debug("Reset handshake state for \(peerID)", category: .handshake)
}
}
@@ -256,8 +247,7 @@ class NoiseHandshakeCoordinator {
if isStale {
stalePeerIDs.append(peerID)
SecureLogger.log("Found stale handshake state for \(peerID): \(state)",
category: SecureLogger.handshake, level: .warning)
SecureLogger.warning("Found stale handshake state for \(peerID): \(state)", category: .handshake)
}
}
@@ -270,8 +260,7 @@ class NoiseHandshakeCoordinator {
for i in 0..<sessionsToRemove {
let peerID = sortedSessions[i].peerID
stalePeerIDs.append(peerID)
SecureLogger.log("Removing old established session for \(peerID) to maintain session limit",
category: SecureLogger.handshake, level: .info)
SecureLogger.info("Removing old established session for \(peerID) to maintain session limit", category: .handshake)
}
}
@@ -281,8 +270,7 @@ class NoiseHandshakeCoordinator {
}
if !stalePeerIDs.isEmpty {
SecureLogger.log("Cleaned up \(stalePeerIDs.count) stale handshake states",
category: SecureLogger.handshake, level: .info)
SecureLogger.info("Cleaned up \(stalePeerIDs.count) stale handshake states", category: .handshake)
}
return stalePeerIDs
@@ -333,7 +321,7 @@ class NoiseHandshakeCoordinator {
/// Log current handshake states for debugging
func logHandshakeStates() {
handshakeQueue.sync {
SecureLogger.log("=== Handshake States ===", category: SecureLogger.handshake, level: .debug)
SecureLogger.debug("=== Handshake States ===", category: .handshake)
for (peerID, state) in handshakeStates {
let stateDesc: String
switch state {
@@ -352,16 +340,16 @@ class NoiseHandshakeCoordinator {
case .failed(let reason, let canRetry, let 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
func clearAllHandshakeStates() {
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.processedHandshakeMessages.removeAll()
}
+13 -14
View File
@@ -79,7 +79,6 @@
import Foundation
import CryptoKit
import os.log
// Core Noise Protocol implementation
// Based on the Noise Protocol Framework specification
@@ -127,7 +126,7 @@ struct NoiseProtocolName {
/// Handles ChaCha20-Poly1305 AEAD encryption with automatic nonce management
/// and replay protection using a sliding window algorithm.
/// - Warning: Nonce reuse would be catastrophic for security
class NoiseCipherState {
final class NoiseCipherState {
// Constants for replay protection
private static let NONCE_SIZE_BYTES = 4
private static let REPLAY_WINDOW_SIZE = 1024
@@ -285,7 +284,7 @@ class NoiseCipherState {
// Log high nonce values that might indicate issues
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
@@ -307,13 +306,13 @@ class NoiseCipherState {
if useExtractedNonce {
// Extract nonce and ciphertext from combined payload
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
}
// Validate nonce with sliding window replay protection
guard isValidNonce(extractedNonce) else {
SecureLogger.log("Replay attack detected: nonce \(extractedNonce) rejected")
SecureLogger.debug("Replay attack detected: nonce \(extractedNonce) rejected")
throw NoiseError.replayDetected
}
@@ -342,7 +341,7 @@ class NoiseCipherState {
// Log high nonce values that might indicate issues
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 {
@@ -355,9 +354,9 @@ class NoiseCipherState {
nonce += 1
return plaintext
} catch {
SecureLogger.log("Decrypt failed: \(error) for nonce \(decryptionNonce)")
SecureLogger.debug("Decrypt failed: \(error) for nonce \(decryptionNonce)")
// 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
}
}
@@ -384,7 +383,7 @@ class NoiseCipherState {
/// Responsible for key derivation, protocol name hashing, and maintaining
/// the chaining key that provides key separation between handshake messages.
/// - Note: This class implements the SymmetricState object from the Noise spec
class NoiseSymmetricState {
final class NoiseSymmetricState {
private var cipherState: NoiseCipherState
private var chainingKey: Data
private var hash: Data
@@ -488,7 +487,7 @@ class NoiseSymmetricState {
/// This is the main interface for establishing encrypted sessions between peers.
/// Manages the handshake state machine, message patterns, and key derivation.
/// - Important: Each handshake instance should only be used once
class NoiseHandshakeState {
final class NoiseHandshakeState {
private let role: NoiseRole
private let pattern: NoisePattern
private var symmetricState: NoiseSymmetricState
@@ -661,7 +660,7 @@ class NoiseHandshakeState {
do {
remoteEphemeralPublic = try NoiseHandshakeState.validatePublicKey(ephemeralData)
} 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
}
symmetricState.mixHash(ephemeralData)
@@ -678,7 +677,7 @@ class NoiseHandshakeState {
let decrypted = try symmetricState.decryptAndHash(staticData)
remoteStaticPublic = try NoiseHandshakeState.validatePublicKey(decrypted)
} catch {
SecureLogger.logSecurityEvent(.authenticationFailed(peerID: "Unknown - handshake"), level: .error)
SecureLogger.error(.authenticationFailed(peerID: "Unknown - handshake"))
throw NoiseError.authenticationFailure
}
@@ -877,7 +876,7 @@ extension NoiseHandshakeState {
// Check against known bad points
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
}
@@ -887,7 +886,7 @@ extension NoiseHandshakeState {
return publicKey
} catch {
// 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
}
}
@@ -61,7 +61,7 @@ struct NoiseSecurityValidator {
// MARK: - Enhanced Noise Session with Security
class SecureNoiseSession: NoiseSession {
final class SecureNoiseSession: NoiseSession {
private(set) var messageCount: UInt64 = 0
private let sessionStartTime = Date()
private(set) var lastActivityTime = Date()
@@ -135,7 +135,7 @@ class SecureNoiseSession: NoiseSession {
// MARK: - Rate Limiter
class NoiseRateLimiter {
final class NoiseRateLimiter {
private var handshakeTimestamps: [String: [Date]] = [:] // peerID -> timestamps
private var messageTimestamps: [String: [Date]] = [:] // peerID -> timestamps
@@ -153,7 +153,7 @@ class NoiseRateLimiter {
// Check global rate limit first
globalHandshakeTimestamps = globalHandshakeTimestamps.filter { $0 > oneMinuteAgo }
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
}
@@ -162,7 +162,7 @@ class NoiseRateLimiter {
timestamps = timestamps.filter { $0 > oneMinuteAgo }
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
}
@@ -182,7 +182,7 @@ class NoiseRateLimiter {
// Check global rate limit first
globalMessageTimestamps = globalMessageTimestamps.filter { $0 > oneSecondAgo }
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
}
@@ -191,7 +191,7 @@ class NoiseRateLimiter {
timestamps = timestamps.filter { $0 > oneSecondAgo }
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
}
+14 -16
View File
@@ -8,7 +8,6 @@
import Foundation
import CryptoKit
import os.log
// MARK: - Noise Session State
@@ -92,7 +91,7 @@ class NoiseSession {
func processHandshakeMessage(_ message: Data) throws -> Data? {
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)
if state == .uninitialized && role == .responder {
@@ -103,7 +102,7 @@ class NoiseSession {
remoteStaticKey: nil
)
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 {
@@ -112,7 +111,7 @@ class NoiseSession {
// Process incoming 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
if handshake.isHandshakeComplete() {
@@ -130,15 +129,15 @@ class NoiseSession {
state = .established
handshakeState = nil // Clear handshake state
SecureLogger.log("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established", category: SecureLogger.noise, level: .debug)
SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID))
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established")
SecureLogger.info(.handshakeCompleted(peerID: peerID))
return nil
} else {
// Generate response
let response = try handshake.writeMessage()
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
if handshake.isHandshakeComplete() {
@@ -156,8 +155,8 @@ class NoiseSession {
state = .established
handshakeState = nil // Clear handshake state
SecureLogger.log("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established", category: SecureLogger.noise, level: .debug)
SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID))
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established")
SecureLogger.info(.handshakeCompleted(peerID: peerID))
}
return response
@@ -242,7 +241,7 @@ class NoiseSession {
handshakeHash = nil
if wasEstablished {
SecureLogger.logSecurityEvent(.sessionExpired(peerID: peerID))
SecureLogger.info(.sessionExpired(peerID: peerID))
}
}
}
@@ -250,7 +249,7 @@ class NoiseSession {
// MARK: - Session Manager
class NoiseSessionManager {
final class NoiseSessionManager {
private var sessions: [String: NoiseSession] = [:]
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
@@ -287,7 +286,7 @@ class NoiseSessionManager {
managerQueue.sync(flags: .barrier) {
if let session = sessions[peerID] {
if session.isEstablished() {
SecureLogger.logSecurityEvent(.sessionExpired(peerID: peerID))
SecureLogger.info(.sessionExpired(peerID: peerID))
}
// Clear sensitive data before removing
session.reset()
@@ -331,7 +330,7 @@ class NoiseSessionManager {
} catch {
// Clean up failed session
_ = sessions.removeValue(forKey: peerID)
SecureLogger.logSecurityEvent(.handshakeFailed(peerID: peerID, error: error.localizedDescription), level: .error)
SecureLogger.error(.handshakeFailed(peerID: peerID, error: error.localizedDescription))
throw error
}
}
@@ -348,8 +347,7 @@ class NoiseSessionManager {
// for a good reason (e.g., decryption failure, restart, etc.)
// We should accept the new handshake to re-establish encryption
if existing.isEstablished() {
SecureLogger.log("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session",
category: SecureLogger.session, level: .info)
SecureLogger.info("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session", category: .session)
_ = sessions.removeValue(forKey: peerID)
shouldCreateNew = true
} else {
@@ -404,7 +402,7 @@ class NoiseSessionManager {
self?.onSessionFailed?(peerID, error)
}
SecureLogger.logSecurityEvent(.handshakeFailed(peerID: peerID, error: error.localizedDescription), level: .error)
SecureLogger.error(.handshakeFailed(peerID: peerID, error: error.localizedDescription))
throw error
}
}
+5 -5
View File
@@ -54,7 +54,7 @@ final class GeoRelayDirectory {
Task.detached {
let ready = await TorManager.shared.awaitReady()
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
}
let task = TorURLSession.shared.session.dataTask(with: req) { [weak self] data, _, error in
@@ -66,12 +66,12 @@ final class GeoRelayDirectory {
self.entries = parsed
self.persistCache(text)
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
}
}
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()
}
@@ -82,7 +82,7 @@ final class GeoRelayDirectory {
do {
try text.data(using: .utf8)?.write(to: url, options: .atomic)
} 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) {
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 []
}
+3 -5
View File
@@ -78,8 +78,7 @@ struct NostrProtocol {
)
// Successfully unwrapped gift wrap
} catch {
SecureLogger.log("❌ Failed to unwrap gift wrap: \(error)",
category: SecureLogger.session, level: .error)
SecureLogger.error("❌ Failed to unwrap gift wrap: \(error)", category: .session)
throw error
}
@@ -92,8 +91,7 @@ struct NostrProtocol {
)
// Successfully opened seal
} catch {
SecureLogger.log("❌ Failed to open seal: \(error)",
category: SecureLogger.session, level: .error)
SecureLogger.error("❌ Failed to open seal: \(error)", category: .session)
throw error
}
@@ -109,7 +107,7 @@ struct NostrProtocol {
teleported: Bool = false
) throws -> NostrEvent {
var tags = [["g", geohash]]
if let nickname = nickname, !nickname.isEmpty {
if let nickname = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty {
tags.append(["n", nickname])
}
if teleported {
+83 -87
View File
@@ -4,7 +4,7 @@ import Combine
/// Manages WebSocket connections to Nostr relays
@MainActor
class NostrRelayManager: ObservableObject {
final class NostrRelayManager: ObservableObject {
static let shared = NostrRelayManager()
// Track gift-wraps (kind 1059) we initiated so we can log OK acks at info
private(set) static var pendingGiftWrapIDs = Set<String>()
@@ -82,10 +82,10 @@ class NostrRelayManager: ObservableObject {
let ready = await TorManager.shared.awaitReady()
await MainActor.run {
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
}
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 {
self.connectToRelay(relay.url)
}
@@ -231,12 +231,11 @@ class NostrRelayManager: ObservableObject {
do {
let message = try encoder.encode(req)
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
}
// SecureLogger.log("📋 Subscription filter JSON: \(messageString.prefix(200))...",
// category: SecureLogger.session, level: .debug)
// SecureLogger.debug("📋 Subscription filter JSON: \(messageString.prefix(200))...", category: .session)
// Target specific relays if provided; else default. Filter permanently failed relays.
let baseUrls = relayUrls ?? Self.defaultRelays
@@ -251,8 +250,9 @@ class NostrRelayManager: ObservableObject {
map[id] = messageString
self.pendingSubscriptions[url] = map
}
SecureLogger.log("📋 Queued subscription id=\(id) for \(urls.count) relay(s)",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session)
// 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
for url in urls {
if let r = relays.first(where: { $0.url == url }), r.isConnected {
@@ -260,8 +260,7 @@ class NostrRelayManager: ObservableObject {
}
}
} catch {
SecureLogger.log("❌ Failed to encode subscription request: \(error)",
category: SecureLogger.session, level: .error)
SecureLogger.error("❌ Failed to encode subscription request: \(error)", category: .session)
}
}
@@ -293,7 +292,7 @@ class NostrRelayManager: ObservableObject {
private func connectToRelay(_ urlString: String) {
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
}
@@ -319,7 +318,7 @@ class NostrRelayManager: ObservableObject {
let ready = await TorManager.shared.awaitReady()
await MainActor.run {
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
@@ -338,14 +337,12 @@ class NostrRelayManager: ObservableObject {
task.sendPing { [weak self] error in
DispatchQueue.main.async {
if error == nil {
SecureLogger.log("✅ Connected to Nostr relay: \(urlString)",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("✅ Connected to Nostr relay: \(urlString)", category: .session)
self?.updateRelayStatus(urlString, isConnected: true)
// Flush any pending subscriptions for this relay
self?.flushPendingSubscriptions(for: urlString)
} else {
SecureLogger.log("❌ Failed to connect to Nostr relay \(urlString): \(error?.localizedDescription ?? "Unknown error")",
category: SecureLogger.session, level: .error)
SecureLogger.error("❌ Failed to connect to Nostr relay \(urlString): \(error?.localizedDescription ?? "Unknown error")", category: .session)
self?.updateRelayStatus(urlString, isConnected: false, error: error)
// Trigger disconnection handler for proper backoff
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 }
connection.send(.string(messageString)) { error in
if let error = error {
SecureLogger.log("❌ Failed to send pending subscription to \(relayUrl): \(error)",
category: SecureLogger.session, level: .error)
SecureLogger.error("❌ Failed to send pending subscription to \(relayUrl): \(error)", category: .session)
} else {
Task { @MainActor in
var subs = self.subscriptions[relayUrl] ?? Set<String>()
@@ -382,26 +378,12 @@ class NostrRelayManager: ObservableObject {
switch result {
case .success(let message):
switch message {
case .string(let text):
// Parse off-main to reduce UI jank, then hop back for state updates
Task.detached(priority: .utility) {
guard let parsed = parseInboundMessage(text) else { return }
await MainActor.run {
NostrRelayManager.shared.handleParsedMessage(parsed, from: relayUrl)
}
// Parse off-main to reduce UI jank, then hop back for state updates
Task.detached(priority: .utility) {
guard let parsed = ParsedInbound(message) else { return }
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
@@ -426,8 +408,7 @@ class NostrRelayManager: ObservableObject {
switch parsed {
case .event(let subId, let event):
if event.kind != 1059 {
SecureLogger.log("📥 Event kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("📥 Event kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
}
if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) {
self.relays[index].messagesReceived += 1
@@ -435,8 +416,7 @@ class NostrRelayManager: ObservableObject {
if let handler = self.messageHandlers[subId] {
handler(event)
} else {
SecureLogger.log("⚠️ No handler for subscription \(subId)",
category: SecureLogger.session, level: .warning)
SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session)
}
case .eose:
// No-op for now
@@ -444,12 +424,14 @@ class NostrRelayManager: ObservableObject {
case .ok(let eventId, let success, let reason):
if success {
_ = Self.pendingGiftWrapIDs.remove(eventId)
SecureLogger.log("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)", category: .session)
} else {
let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil
SecureLogger.log("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)",
category: SecureLogger.session, level: isGiftWrap ? .warning : .error)
if isGiftWrap {
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:
break
@@ -463,17 +445,14 @@ class NostrRelayManager: ObservableObject {
let data = try encoder.encode(req)
let message = String(data: data, encoding: .utf8) ?? ""
SecureLogger.log("📤 Send kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("📤 Send kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
connection.send(.string(message)) { [weak self] error in
DispatchQueue.main.async {
if let error = error {
SecureLogger.log("❌ Failed to send event to \(relayUrl): \(error)",
category: SecureLogger.session, level: .error)
SecureLogger.error("❌ Failed to send event to \(relayUrl): \(error)", category: .session)
} else {
// SecureLogger.log(" Event sent to relay: \(relayUrl)",
// category: SecureLogger.session, level: .debug)
// SecureLogger.debug(" Event sent to relay: \(relayUrl)", category: .session)
// Update relay stats
if let index = self?.relays.firstIndex(where: { $0.url == relayUrl }) {
self?.relays[index].messagesSent += 1
@@ -482,7 +461,7 @@ class NostrRelayManager: ObservableObject {
}
}
} 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") ||
(ns.domain == NSURLErrorDomain && ns.code == NSURLErrorBadServerResponse) {
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 }) {
relays[index].lastError = error
@@ -539,8 +518,7 @@ class NostrRelayManager: ObservableObject {
// Stop attempting after max attempts
if relays[index].reconnectAttempts >= maxReconnectAttempts {
SecureLogger.log("Max reconnection attempts (\(maxReconnectAttempts)) reached for \(relayUrl)",
category: SecureLogger.session, level: .warning)
SecureLogger.warning("Max reconnection attempts (\(maxReconnectAttempts)) reached for \(relayUrl)", category: .session)
return
}
@@ -634,42 +612,60 @@ private enum ParsedInbound {
case ok(eventId: String, success: Bool, reason: String)
case eose(subscriptionId: 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 func parseInboundMessage(_ message: String) -> ParsedInbound? {
guard let data = message.data(using: .utf8) else { return nil }
do {
if let array = try JSONSerialization.jsonObject(with: data) as? [Any],
array.count >= 2,
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
}
private extension URLSessionWebSocketTask.Message {
var data: Data? {
switch self {
case .string(let text): text.data(using: .utf8)
case .data(let data): data
@unknown default: nil
}
} catch {
// Ignore
}
return nil
}
// MARK: - Nostr Protocol Types
+1 -1
View File
@@ -397,7 +397,7 @@ enum DeliveryStatus: Codable, Equatable {
/// Handles both broadcast messages and private encrypted messages,
/// with support for mentions, replies, and delivery tracking.
/// - Note: This is the primary data model for chat messages
class BitchatMessage: Codable {
final class BitchatMessage: Codable {
let id: String
let sender: String
let content: String
+1 -1
View File
@@ -9,7 +9,7 @@
import Foundation
/// Manages autocomplete functionality for chat
class AutocompleteService {
final class AutocompleteService {
private let mentionRegex = try? NSRegularExpression(pattern: "@([\\p{L}0-9_]*)$", 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
// If single chunk exceeds cap, drop it immediately
if newSize > capBytes {
SecureLogger.log("⚠️ Dropping oversized write chunk (\(newSize)B) for peripheral \(uuid)",
category: SecureLogger.session, level: .warning)
SecureLogger.warning("⚠️ Dropping oversized write chunk (\(newSize)B) for peripheral \(uuid)", category: .session)
} else {
// Append and trim from the front to respect cap
var total = queue.reduce(0) { $0 + $1.count }
@@ -244,8 +243,7 @@ final class BLEService: NSObject {
removedBytes += removed.count
total -= removed.count
}
SecureLogger.log("📉 Trimmed pending write buffer for \(uuid) by \(removedBytes)B to \(total)B",
category: SecureLogger.session, level: .warning)
SecureLogger.warning("📉 Trimmed pending write buffer for \(uuid) by \(removedBytes)B to \(total)B", category: .session)
}
self.pendingPeripheralWrites[uuid] = queue.isEmpty ? nil : queue
}
@@ -341,8 +339,7 @@ final class BLEService: NSObject {
// Set up Noise session establishment callback
// This ensures we send pending messages only when session is truly established
noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in
SecureLogger.log("🔐 Noise session authenticated with \(peerID), fingerprint: \(fingerprint.prefix(16))...",
category: SecureLogger.noise, level: .debug)
SecureLogger.debug("🔐 Noise session authenticated with \(peerID), fingerprint: \(fingerprint.prefix(16))...")
// Send any messages that were queued during handshake
self?.messageQueue.async { [weak self] in
self?.sendPendingMessagesAfterHandshake(for: peerID)
@@ -587,8 +584,7 @@ final class BLEService: NSObject {
}
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
SecureLogger.log("🔔 sendFavoriteNotification called - peerID: \(peerID), isFavorite: \(isFavorite)",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("🔔 sendFavoriteNotification called - peerID: \(peerID), isFavorite: \(isFavorite)", category: .session)
// Include Nostr public key in the notification
var content = isFavorite ? "[FAVORITED]" : "[UNFAVORITED]"
@@ -596,12 +592,10 @@ final class BLEService: NSObject {
// Add our Nostr public key if available
if let myNostrIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() {
content += ":" + myNostrIdentity.npub
SecureLogger.log("📝 Sending favorite notification with Nostr npub: \(myNostrIdentity.npub)",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("📝 Sending favorite notification with Nostr npub: \(myNostrIdentity.npub)", category: .session)
}
SecureLogger.log("📤 Sending favorite notification to \(peerID): \(content)",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("📤 Sending favorite notification to \(peerID): \(content)", category: .session)
sendPrivateMessage(content, to: peerID, messageID: UUID().uuidString)
}
@@ -611,8 +605,7 @@ final class BLEService: NSObject {
payload.append(contentsOf: receipt.originalMessageID.utf8)
if noiseService.hasEstablishedSession(with: peerID) {
SecureLogger.log("📤 Sending READ receipt for message \(receipt.originalMessageID) to \(peerID)",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("📤 Sending READ receipt for message \(receipt.originalMessageID) to \(peerID)", category: .session)
do {
let encrypted = try noiseService.encrypt(payload, for: peerID)
let packet = BitchatPacket(
@@ -630,7 +623,7 @@ final class BLEService: NSObject {
messageQueue.async { [weak self] in self?.broadcastPacket(packet) }
}
} catch {
SecureLogger.log("Failed to send read receipt: \(error)", category: SecureLogger.noise, level: .error)
SecureLogger.error("Failed to send read receipt: \(error)")
}
} else {
// Queue for after handshake and initiate if needed
@@ -639,8 +632,7 @@ final class BLEService: NSObject {
self.pendingNoisePayloadsAfterHandshake[peerID, default: []].append(payload)
}
if !noiseService.hasSession(with: peerID) { initiateNoiseHandshake(with: peerID) }
SecureLogger.log("🕒 Queued READ receipt for \(peerID) until handshake completes",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("🕒 Queued READ receipt for \(peerID) until handshake completes", category: .session)
}
}
@@ -682,7 +674,7 @@ final class BLEService: NSObject {
messageQueue.async { [weak self] in self?.broadcastPacket(packet) }
}
} 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 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
}
@@ -771,7 +763,7 @@ final class BLEService: NSObject {
ttl: self.messageTTL
)
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
}
// 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
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
if noiseService.hasEstablishedSession(with: recipientID) {
@@ -798,7 +790,7 @@ final class BLEService: NSObject {
// Create TLV-encoded private message
let privateMessage = PrivateMessagePacket(messageID: messageID, content: content)
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
}
@@ -847,11 +839,11 @@ final class BLEService: NSObject {
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent)
}
} catch {
SecureLogger.log("Failed to encrypt message: \(error)", category: SecureLogger.noise, level: .error)
SecureLogger.error("Failed to encrypt message: \(error)")
}
} else {
// 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)
collectionsQueue.sync(flags: .barrier) {
@@ -896,7 +888,7 @@ final class BLEService: NSObject {
}
}
} 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 }
SecureLogger.log("📤 Sending \(messages.count) pending messages after handshake to \(peerID)",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("📤 Sending \(messages.count) pending messages after handshake to \(peerID)", category: .session)
// Send each pending message directly (we know session is established)
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
let privateMessage = PrivateMessagePacket(messageID: messageID, content: content)
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
}
@@ -946,11 +937,9 @@ final class BLEService: NSObject {
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent)
}
SecureLogger.log("✅ Sent pending message \(messageID) to \(peerID) after handshake",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("✅ Sent pending message \(messageID) to \(peerID) after handshake", category: .session)
} catch {
SecureLogger.log("Failed to send pending message after handshake: \(error)",
category: SecureLogger.noise, level: .error)
SecureLogger.error("Failed to send pending message after handshake: \(error)")
// Notify delegate of failure
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
let padForBLE = padPolicy(for: packet.type)
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
}
if packet.type == MessageType.noiseEncrypted.rawValue {
@@ -1037,7 +1026,7 @@ final class BLEService: NSObject {
guard let self = self else { return }
if self.pendingNotifications.count < TransportConfig.blePendingNotificationsCapCount {
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 {
byMsg[msgID] = (packet: packet, enqueuedAt: Date())
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) {
handleReceivedPacket(originalPacket, from: peerID)
} 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
@@ -1342,8 +1331,7 @@ final class BLEService: NSObject {
// Only log non-announce packets to reduce noise
if packet.type != MessageType.announce.rawValue {
// Log packet details for debugging
SecureLogger.log("📦 Handling packet type \(packet.type) from \(senderID), messageID: \(messageID)",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("📦 Handling packet type \(packet.type) from \(senderID), messageID: \(messageID)", category: .session)
}
// Efficient deduplication
@@ -1352,8 +1340,7 @@ final class BLEService: NSObject {
// 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
if packet.type != MessageType.announce.rawValue {
SecureLogger.log("⚠️ Duplicate packet ignored: \(messageID)",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("⚠️ Duplicate packet ignored: \(messageID)", category: .session)
}
// In sparse graphs (<=2 neighbors), keep the pending relay to ensure bridging.
// In denser graphs, cancel the pending relay to reduce redundant floods.
@@ -1406,7 +1393,7 @@ final class BLEService: NSObject {
handleLeave(packet, from: senderID)
default:
SecureLogger.log("⚠️ Unknown message type: \(packet.type)", category: SecureLogger.session, level: .warning)
SecureLogger.warning("⚠️ Unknown message type: \(packet.type)", category: .session)
break
}
@@ -1446,7 +1433,7 @@ final class BLEService: NSObject {
private func handleAnnounce(_ packet: BitchatPacket, from peerID: String) {
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
}
@@ -1454,7 +1441,7 @@ final class BLEService: NSObject {
// This helps detect relayed or spoofed announces. Only warn in release; assert in debug.
let derivedFromKey = PeerIDUtils.derivePeerID(fromPublicKey: announcement.noisePublicKey)
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 {
verifiedAnnounce = noiseService.verifyPacketSignature(packet, publicKey: announcement.signingPublicKey)
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 {
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
}
@@ -1508,7 +1495,7 @@ final class BLEService: NSObject {
// Require verified announce; ignore otherwise (no backward compatibility)
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
}
@@ -1541,17 +1528,17 @@ final class BLEService: NSObject {
if isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription {
let now = Date()
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 {
// Debounce 'reconnected' logs within short window
if let last = lastReconnectLogAt[peerID], now.timeIntervalSince(last) < TransportConfig.bleReconnectLogDebounceSeconds {
// Skip duplicate log
} else {
SecureLogger.log("🔄 Peer \(announcement.nickname) reconnected", category: SecureLogger.session, level: .debug)
SecureLogger.debug("🔄 Peer \(announcement.nickname) reconnected", category: .session)
lastReconnectLogAt[peerID] = now
}
} 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 {
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
}
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
}
// Determine if we have a direct link to the sender
@@ -1678,7 +1665,7 @@ final class BLEService: NSObject {
}
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)
notifyUI { [weak self] in
@@ -1710,7 +1697,7 @@ final class BLEService: NSObject {
// Session establishment will trigger onPeerAuthenticated callback
// which will send any pending messages at the right time
} 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
if !noiseService.hasSession(with: peerID) {
initiateNoiseHandshake(with: peerID)
@@ -1720,17 +1707,16 @@ final class BLEService: NSObject {
}
private func handleNoiseEncrypted(_ packet: BitchatPacket, from peerID: String) {
SecureLogger.log("🔐 handleNoiseEncrypted called for packet from \(peerID)",
category: SecureLogger.noise, level: .debug)
SecureLogger.debug("🔐 handleNoiseEncrypted called for packet from \(peerID)")
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
}
let recipientHex = recipientID.hexEncodedString()
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
}
@@ -1772,19 +1758,17 @@ final class BLEService: NSObject {
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyResponse, payload: Data(payloadData), timestamp: ts)
}
default:
SecureLogger.log("⚠️ Unknown noise payload type: \(payloadType)", category: SecureLogger.noise, level: .warning)
SecureLogger.warning("⚠️ Unknown noise payload type: \(payloadType)")
}
} catch NoiseEncryptionError.sessionNotEstablished {
// We received an encrypted message before establishing a session with this peer.
// Trigger a handshake so future messages can be decrypted.
SecureLogger.log("🔑 Encrypted message from \(peerID) without session; initiating handshake",
category: SecureLogger.noise, level: .debug)
SecureLogger.debug("🔑 Encrypted message from \(peerID) without session; initiating handshake")
if !noiseService.hasSession(with: peerID) {
initiateNoiseHandshake(with: peerID)
}
} catch {
SecureLogger.log("❌ Failed to decrypt message from \(peerID): \(error)",
category: SecureLogger.noise, level: .error)
SecureLogger.error("❌ Failed to decrypt message from \(peerID): \(error)")
}
}
@@ -1808,7 +1792,7 @@ final class BLEService: NSObject {
// MARK: - Helper Functions
private func sendLeave() {
SecureLogger.log("👋 Sending leave announcement", category: SecureLogger.session, level: .debug)
SecureLogger.debug("👋 Sending leave announcement", category: .session)
let packet = BitchatPacket(
type: MessageType.leave.rawValue,
ttl: messageTTL,
@@ -1845,7 +1829,7 @@ final class BLEService: NSObject {
)
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
}
@@ -1862,7 +1846,7 @@ final class BLEService: NSObject {
// Sign the packet using the noise private key
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
}
@@ -1895,7 +1879,7 @@ final class BLEService: NSObject {
)
broadcastPacket(packet)
} catch {
SecureLogger.log("Failed to send delivery ACK: \(error)", category: SecureLogger.noise, level: .error)
SecureLogger.error("Failed to send delivery ACK: \(error)")
}
} else {
// Queue for after handshake and initiate if needed
@@ -1904,8 +1888,7 @@ final class BLEService: NSObject {
self.pendingNoisePayloadsAfterHandshake[peerID, default: []].append(payload)
}
if !noiseService.hasSession(with: peerID) { initiateNoiseHandshake(with: peerID) }
SecureLogger.log("🕒 Queued DELIVERED ack for \(peerID) until handshake completes",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("🕒 Queued DELIVERED ack for \(peerID) until handshake completes", category: .session)
}
}
@@ -1916,8 +1899,7 @@ final class BLEService: NSObject {
return list
}
guard !payloads.isEmpty else { return }
SecureLogger.log("📤 Sending \(payloads.count) pending noise payloads to \(peerID) after handshake",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("📤 Sending \(payloads.count) pending noise payloads to \(peerID) after handshake", category: .session)
for payload in payloads {
do {
let encrypted = try noiseService.encrypt(payload, for: peerID)
@@ -1932,8 +1914,7 @@ final class BLEService: NSObject {
)
broadcastPacket(packet)
} catch {
SecureLogger.log("❌ Failed to send pending noise payload to \(peerID): \(error)",
category: SecureLogger.noise, level: .error)
SecureLogger.error("❌ Failed to send pending noise payload to \(peerID): \(error)")
}
}
}
@@ -2103,8 +2084,7 @@ final class BLEService: NSObject {
// Cleanup: remove peers that are not connected and past reachability retention
if !peer.isConnected {
if age > retention {
SecureLogger.log("🗑️ Removing stale peer after reachability window: \(peerID) (\(peer.nickname))",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("🗑️ Removing stale peer after reachability window: \(peerID) (\(peer.nickname))", category: .session)
peers.removeValue(forKey: peerID)
removedOfflineCount += 1
}
@@ -2395,8 +2375,7 @@ extension BLEService: CBCentralManagerDelegate {
peripheral.delegate = self
// Connect to the peripheral with options for faster connection
SecureLogger.log("📱 Connect: \(advertisedName) [RSSI:\(rssiValue)]",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("📱 Connect: \(advertisedName) [RSSI:\(rssiValue)]", category: .session)
// Use connection options for faster reconnection
let options: [String: Any] = [
@@ -2415,8 +2394,7 @@ extension BLEService: CBCentralManagerDelegate {
state.isConnecting && !state.isConnected else { return }
// Connection timed out - cancel it
SecureLogger.log("⏱️ Timeout: \(advertisedName)",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("⏱️ Timeout: \(advertisedName)", category: .session)
central.cancelPeripheralConnection(peripheral)
self.peripherals[peripheralID] = nil
self.recentConnectTimeouts[peripheralID] = Date()
@@ -2449,7 +2427,7 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
failureCounts[peripheralID] = 0
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
peripheral.discoverServices([BLEService.serviceUUID])
@@ -2461,8 +2439,7 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
// Find the peer ID if we have it
let peerID = peripherals[peripheralID]?.peerID
SecureLogger.log("📱 Disconnect: \(peerID ?? peripheralID)\(error != nil ? " (\(error!.localizedDescription))" : "")",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("📱 Disconnect: \(peerID ?? peripheralID)\(error != nil ? " (\(error!.localizedDescription))" : "")", category: .session)
// If disconnect carried an error (often timeout), apply short backoff to avoid thrash
if error != nil {
@@ -2517,7 +2494,7 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
// Clean up the references
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
// Try next candidate
bleQueue.async { [weak self] in self?.tryConnectFromQueue() }
@@ -2590,7 +2567,7 @@ extension BLEService {
]
central.connect(peripheral, options: options)
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 {
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices 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
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
guard peripheral.state == .connected else { return }
@@ -2645,7 +2622,7 @@ extension BLEService: CBPeripheralDelegate {
}
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
}
@@ -2661,12 +2638,12 @@ extension BLEService: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, 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
}
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
}
@@ -2683,7 +2660,7 @@ extension BLEService: CBPeripheralDelegate {
// Verify characteristic supports reliable writes
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
@@ -2696,7 +2673,7 @@ extension BLEService: CBPeripheralDelegate {
// Subscribe for notifications
if characteristic.properties.contains(.notify) {
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)
messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostSubscribeAnnounceDelaySeconds) { [weak self] in
@@ -2707,18 +2684,18 @@ extension BLEService: CBPeripheralDelegate {
self?.rebroadcastRecentAnnounces()
}
} 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?) {
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
}
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
}
@@ -2728,8 +2705,7 @@ extension BLEService: CBPeripheralDelegate {
guard let packet = BinaryProtocol.decode(data) else {
// Avoid dumping entire payload; log size and short prefix for diagnostics
let prefix = data.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")
SecureLogger.log("❌ Failed to decode notification packet (len=\(data.count), prefix=\(prefix))",
category: SecureLogger.session, level: .error)
SecureLogger.error("❌ Failed to decode notification packet (len=\(data.count), prefix=\(prefix))", category: .session)
return
}
@@ -2737,7 +2713,7 @@ extension BLEService: CBPeripheralDelegate {
let senderID = packet.senderID.hexEncodedString()
// Only log non-announce packets
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
@@ -2775,10 +2751,10 @@ extension BLEService: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, 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
} 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]) {
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)
let hasOurService = peripheral.services?.contains { $0.uuid == BLEService.serviceUUID } ?? false
if !hasOurService {
// 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)
} else {
// Try to rediscover
@@ -2805,9 +2781,9 @@ extension BLEService: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, 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 {
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 characteristic.isNotifying {
@@ -2822,7 +2798,7 @@ extension BLEService: CBPeripheralDelegate {
extension BLEService: CBPeripheralManagerDelegate {
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 {
// Remove all services first to ensure clean state
@@ -2841,28 +2817,28 @@ extension BLEService: CBPeripheralManagerDelegate {
service.characteristics = [characteristic!]
// 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)
}
}
func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, 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
}
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
let adData = buildAdvertisementData()
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) {
SecureLogger.log("📥 Central subscribed: \(central.identifier.uuidString)", category: SecureLogger.session, level: .debug)
SecureLogger.debug("📥 Central subscribed: \(central.identifier.uuidString)", category: .session)
subscribedCentrals.append(central)
// Send announce to the newly subscribed central after a small delay to avoid overwhelming
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) {
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 }
// Ensure we're still advertising for other devices to find us
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())
}
@@ -2914,7 +2890,7 @@ extension BLEService: CBPeripheralManagerDelegate {
}
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
collectionsQueue.async(flags: .barrier) { [weak self] in
@@ -2933,12 +2909,10 @@ extension BLEService: CBPeripheralManagerDelegate {
if !success {
// Still full, re-queue
self.pendingNotifications.append((data: data, centrals: centrals))
SecureLogger.log("⚠️ Notification queue still full, re-queuing",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("⚠️ Notification queue still full, re-queuing", category: .session)
break // Stop trying, wait for next ready callback
} else {
SecureLogger.log("✅ Sent pending notification from retry queue",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("✅ Sent pending notification from retry queue", category: .session)
}
} else {
// Broadcast to all
@@ -2952,8 +2926,7 @@ extension BLEService: CBPeripheralManagerDelegate {
}
if !self.pendingNotifications.isEmpty {
SecureLogger.log("📋 Still have \(self.pendingNotifications.count) pending notifications",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("📋 Still have \(self.pendingNotifications.count) pending notifications", category: .session)
}
}
}
@@ -2961,7 +2934,7 @@ extension BLEService: CBPeripheralManagerDelegate {
func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) {
// Suppress logs for single write requests to reduce noise
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!
@@ -3000,7 +2973,7 @@ extension BLEService: CBPeripheralManagerDelegate {
if combined.count >= 2 {
let peekType = combined[1]
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)
let senderID = packet.senderID.hexEncodedString()
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) {
subscribedCentrals.append(sorted[0].central)
@@ -3035,12 +3008,12 @@ extension BLEService: CBPeripheralManagerDelegate {
// If buffer grows suspiciously large, reset to avoid memory leak
if combined.count > TransportConfig.blePendingWriteBufferCapBytes { // cap for safety
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 !hasMultiple, let only = sorted.first, let raw = only.value {
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
@MainActor
class CommandProcessor {
final class CommandProcessor {
weak var chatViewModel: ChatViewModel?
weak var meshService: Transport?
@@ -50,9 +50,9 @@ class CommandProcessor {
case "/clear":
return handleClear()
case "/hug":
return handleEmote(args, action: "hugs", emoji: "🫂")
return handleEmote(args, command: "hug", action: "hugs", emoji: "🫂")
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":
return handleBlock(args)
case "/unblock":
@@ -129,17 +129,17 @@ class CommandProcessor {
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)
guard !targetName.isEmpty else {
return .error(message: "usage: /\(action) <nickname>")
return .error(message: "usage: /\(command) <nickname>")
}
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
guard let targetPeerID = chatViewModel?.getPeerIDForNickname(nickname),
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) *"
@@ -3,7 +3,7 @@ import Combine
/// Manages persistent favorite relationships between peers
@MainActor
class FavoritesPersistenceService: ObservableObject {
final class FavoritesPersistenceService: ObservableObject {
struct FavoriteRelationship: Codable {
let peerNoisePublicKey: Data
@@ -13,12 +13,16 @@ class FavoritesPersistenceService: ObservableObject {
let theyFavoritedUs: Bool
let favoritedAt: 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 {
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 keychainService = "chat.bitchat.favorites"
@@ -47,8 +51,7 @@ class FavoritesPersistenceService: ObservableObject {
peerNostrPublicKey: String? = nil,
peerNickname: String
) {
SecureLogger.log("⭐️ Adding favorite: \(peerNickname) (\(peerNoisePublicKey.hexEncodedString()))",
category: SecureLogger.session, level: .info)
SecureLogger.info("⭐️ Adding favorite: \(peerNickname) (\(peerNoisePublicKey.hexEncodedString()))", category: .session)
let existing = favorites[peerNoisePublicKey]
@@ -64,8 +67,7 @@ class FavoritesPersistenceService: ObservableObject {
// Log if this creates a mutual favorite
if relationship.isMutual {
SecureLogger.log("💕 Mutual favorite relationship established with \(peerNickname)!",
category: SecureLogger.session, level: .info)
SecureLogger.info("💕 Mutual favorite relationship established with \(peerNickname)!", category: .session)
}
favorites[peerNoisePublicKey] = relationship
@@ -83,8 +85,7 @@ class FavoritesPersistenceService: ObservableObject {
func removeFavorite(peerNoisePublicKey: Data) {
guard let existing = favorites[peerNoisePublicKey] else { return }
SecureLogger.log("⭐️ Removing favorite: \(existing.peerNickname) (\(peerNoisePublicKey.hexEncodedString()))",
category: SecureLogger.session, level: .info)
SecureLogger.info("⭐️ Removing favorite: \(existing.peerNickname) (\(peerNoisePublicKey.hexEncodedString()))", category: .session)
// If they still favorite us, keep the record but mark us as not favoriting
if existing.theyFavoritedUs {
@@ -125,8 +126,7 @@ class FavoritesPersistenceService: ObservableObject {
let existing = favorites[peerNoisePublicKey]
let displayName = peerNickname ?? existing?.peerNickname ?? "Unknown"
SecureLogger.log("📨 Received favorite notification: \(displayName) \(favorited ? "favorited" : "unfavorited") us",
category: SecureLogger.session, level: .info)
SecureLogger.info("📨 Received favorite notification: \(displayName) \(favorited ? "favorited" : "unfavorited") us", category: .session)
let relationship = FavoriteRelationship(
peerNoisePublicKey: peerNoisePublicKey,
@@ -147,8 +147,7 @@ class FavoritesPersistenceService: ObservableObject {
// Check if this creates a mutual favorite
if relationship.isMutual {
SecureLogger.log("💕 Mutual favorite relationship established with \(displayName)!",
category: SecureLogger.session, level: .info)
SecureLogger.info("💕 Mutual favorite relationship established with \(displayName)!", category: .session)
}
}
@@ -240,15 +239,13 @@ class FavoritesPersistenceService: ObservableObject {
/// Update noise public key when peer reconnects with new ID
func updateNoisePublicKey(from oldKey: Data, to newKey: Data, peerNickname: String) {
guard let existing = favorites[oldKey] else {
SecureLogger.log("⚠️ Cannot update noise key - no favorite found for \(oldKey.hexEncodedString())",
category: SecureLogger.session, level: .warning)
SecureLogger.warning("⚠️ Cannot update noise key - no favorite found for \(oldKey.hexEncodedString())", category: .session)
return
}
// Check if we already have a favorite with the new key
if favorites[newKey] != nil {
SecureLogger.log("⚠️ Favorite already exists with new key \(newKey.hexEncodedString()), removing old entry",
category: SecureLogger.session, level: .warning)
SecureLogger.warning("⚠️ Favorite already exists with new key \(newKey.hexEncodedString()), removing old entry", category: .session)
favorites.removeValue(forKey: oldKey)
saveFavorites()
return
@@ -302,7 +299,7 @@ class FavoritesPersistenceService: ObservableObject {
/// Clear all favorites - used for panic mode
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()
saveFavorites()
@@ -336,7 +333,7 @@ class FavoritesPersistenceService: ObservableObject {
// Successfully saved favorites
} 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 relationships = try decoder.decode([FavoriteRelationship].self, from: data)
SecureLogger.log("✅ Loaded \(relationships.count) favorite relationships",
category: SecureLogger.session, level: .info)
SecureLogger.info("✅ Loaded \(relationships.count) favorite relationships", category: .session)
// Log Nostr public key info
for relationship in relationships {
if relationship.peerNostrPublicKey == nil {
SecureLogger.log("⚠️ No Nostr public key stored for '\(relationship.peerNickname)'",
category: SecureLogger.session, level: .warning)
SecureLogger.warning("⚠️ No Nostr public key stored for '\(relationship.peerNickname)'", category: .session)
}
}
@@ -372,8 +367,7 @@ class FavoritesPersistenceService: ObservableObject {
for relationship in relationships {
// Check for duplicates by public key (the actual unique identifier)
if let existing = seenPublicKeys[relationship.peerNoisePublicKey] {
SecureLogger.log("⚠️ Duplicate favorite found for public key \(relationship.peerNoisePublicKey.hexEncodedString()) - nicknames: '\(existing.peerNickname)' vs '\(relationship.peerNickname)'",
category: SecureLogger.session, level: .warning)
SecureLogger.warning("⚠️ Duplicate favorite found for public key \(relationship.peerNoisePublicKey.hexEncodedString()) - nicknames: '\(existing.peerNickname)' vs '\(relationship.peerNickname)'", category: .session)
// Keep the most recent or most complete relationship
if relationship.lastUpdated > existing.lastUpdated ||
@@ -414,7 +408,7 @@ class FavoritesPersistenceService: ObservableObject {
// Log loaded relationships
// Loaded relationships successfully
} 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 Security
import os.log
class KeychainManager {
final class KeychainManager {
static let shared = KeychainManager()
// Use consistent service name for all keychain items
@@ -53,7 +52,7 @@ class KeychainManager {
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
let fullKey = "identity_\(key)"
let result = saveData(keyData, forKey: fullKey)
SecureLogger.logKeyOperation("save", keyType: key, success: result)
SecureLogger.logKeyOperation(.save, keyType: key, success: result)
return result
}
@@ -64,7 +63,7 @@ class KeychainManager {
func deleteIdentityKey(forKey key: String) -> Bool {
let result = delete(forKey: "identity_\(key)")
SecureLogger.logKeyOperation("delete", keyType: key, success: result)
SecureLogger.logKeyOperation(.delete, keyType: key, success: result)
return result
}
@@ -113,9 +112,9 @@ class KeychainManager {
if status == errSecSuccess { return true }
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 {
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
}
@@ -151,7 +150,7 @@ class KeychainManager {
if status == errSecSuccess { return result as? Data }
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
}
@@ -198,7 +197,7 @@ class KeychainManager {
// Delete ALL keychain data for panic mode
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
@@ -261,7 +260,7 @@ class KeychainManager {
let deleteStatus = SecItemDelete(deleteQuery as CFDictionary)
if deleteStatus == errSecSuccess {
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
}
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
}
@@ -197,8 +197,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
// Surface as denied/restricted if relevant; otherwise keep previous state
SecureLogger.log("LocationChannelManager: location error: \(error.localizedDescription)",
category: SecureLogger.session, level: .error)
SecureLogger.error("LocationChannelManager: location error: \(error.localizedDescription)", category: .session)
}
// MARK: - Helpers
+10 -18
View File
@@ -39,40 +39,34 @@ final class MessageRouter {
func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
let reachableMesh = mesh.isPeerReachable(peerID)
if reachableMesh {
SecureLogger.log("Routing PM via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("Routing PM via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))", category: .session)
// BLEService will initiate a handshake if needed and queue the message
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
} else if canSendViaNostr(peerID: peerID) {
SecureLogger.log("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))", category: .session)
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
} else {
// Queue for later (when mesh connects or Nostr mapping appears)
if outbox[peerID] == nil { outbox[peerID] = [] }
outbox[peerID]?.append((content, recipientNickname, messageID))
SecureLogger.log("Queued PM for \(peerID.prefix(8))… (no mesh, no Nostr mapping) id=\(messageID.prefix(8))",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("Queued PM for \(peerID.prefix(8))… (no mesh, no Nostr mapping) id=\(messageID.prefix(8))", category: .session)
}
}
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
// Prefer mesh for reachable peers; BLE will queue if handshake is needed
if mesh.isPeerReachable(peerID) {
SecureLogger.log("Routing READ ack via mesh (reachable) to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("Routing READ ack via mesh (reachable) to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))", category: .session)
mesh.sendReadReceipt(receipt, to: peerID)
} else {
SecureLogger.log("Routing READ ack via Nostr to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("Routing READ ack via Nostr to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))", category: .session)
nostr.sendReadReceipt(receipt, to: peerID)
}
}
func sendDeliveryAck(_ messageID: String, to peerID: String) {
if mesh.isPeerReachable(peerID) {
SecureLogger.log("Routing DELIVERED ack via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("Routing DELIVERED ack via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))", category: .session)
mesh.sendDeliveryAck(for: messageID, to: peerID)
} else {
nostr.sendDeliveryAck(for: messageID, to: peerID)
@@ -80,6 +74,7 @@ final class MessageRouter {
}
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
// Route via mesh when connected; else use Nostr
if mesh.isPeerConnected(peerID) {
mesh.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
} else {
@@ -108,18 +103,15 @@ final class MessageRouter {
func flushOutbox(for peerID: String) {
guard let queued = outbox[peerID], !queued.isEmpty else { return }
SecureLogger.log("Flushing outbox for \(peerID.prefix(8))… count=\(queued.count)",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("Flushing outbox for \(peerID.prefix(8))… count=\(queued.count)", category: .session)
var remaining: [(content: String, nickname: String, messageID: String)] = []
// Prefer mesh if connected; else try Nostr if mapping exists
for (content, nickname, messageID) in queued {
if mesh.isPeerReachable(peerID) {
SecureLogger.log("Outbox -> mesh for \(peerID.prefix(8))… id=\(messageID.prefix(8))",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("Outbox -> mesh for \(peerID.prefix(8))… id=\(messageID.prefix(8))", category: .session)
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
} else if canSendViaNostr(peerID: peerID) {
SecureLogger.log("Outbox -> Nostr for \(peerID.prefix(8))… id=\(messageID.prefix(8))",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("Outbox -> Nostr for \(peerID.prefix(8))… id=\(messageID.prefix(8))", category: .session)
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
} else {
// Keep unsent items queued
+19 -20
View File
@@ -85,7 +85,6 @@
import Foundation
import CryptoKit
import os.log
// MARK: - Encryption Status
@@ -135,7 +134,7 @@ enum EncryptionStatus: Equatable {
/// Provides a high-level API for establishing secure channels between peers,
/// handling all cryptographic operations transparently.
/// - Important: This service maintains the device's cryptographic identity
class NoiseEncryptionService {
final class NoiseEncryptionService {
// Static identity key (persistent across sessions)
private let staticIdentityKey: Curve25519.KeyAgreement.PrivateKey
public let staticIdentityPublicKey: Curve25519.KeyAgreement.PublicKey
@@ -190,7 +189,7 @@ class NoiseEncryptionService {
if let identityData = KeychainManager.shared.getIdentityKey(forKey: "noiseStaticKey"),
let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: identityData) {
loadedKey = key
SecureLogger.logKeyOperation("load", keyType: "noiseStaticKey", success: true)
SecureLogger.logKeyOperation(.load, keyType: "noiseStaticKey", success: true)
}
// If no identity exists, create new one
else {
@@ -199,7 +198,7 @@ class NoiseEncryptionService {
// Save to keychain
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
@@ -213,7 +212,7 @@ class NoiseEncryptionService {
if let signingData = KeychainManager.shared.getIdentityKey(forKey: "ed25519SigningKey"),
let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingData) {
loadedSigningKey = key
SecureLogger.logKeyOperation("load", keyType: "ed25519SigningKey", success: true)
SecureLogger.logKeyOperation(.load, keyType: "ed25519SigningKey", success: true)
}
// If no signing key exists, create new one
else {
@@ -222,7 +221,7 @@ class NoiseEncryptionService {
// Save to keychain
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
@@ -269,8 +268,8 @@ class NoiseEncryptionService {
// Clear from keychain
let deletedStatic = KeychainManager.shared.deleteIdentityKey(forKey: "noiseStaticKey")
let deletedSigning = KeychainManager.shared.deleteIdentityKey(forKey: "ed25519SigningKey")
SecureLogger.logKeyOperation("delete", keyType: "identity keys", success: deletedStatic && deletedSigning)
SecureLogger.log("Panic mode activated - identity cleared", category: SecureLogger.security, level: .warning)
SecureLogger.logKeyOperation(.delete, keyType: "identity keys", success: deletedStatic && deletedSigning)
SecureLogger.warning("Panic mode activated - identity cleared", category: .security)
// Stop rekey timer
stopRekeyTimer()
}
@@ -281,7 +280,7 @@ class NoiseEncryptionService {
let signature = try signingKey.signature(for: data)
return signature
} catch {
SecureLogger.logError(error, context: "Failed to sign data", category: SecureLogger.noise)
SecureLogger.error(error, context: "Failed to sign data")
return nil
}
}
@@ -292,7 +291,7 @@ class NoiseEncryptionService {
let signingPublicKey = try Curve25519.Signing.PublicKey(rawRepresentation: publicKey)
return signingPublicKey.isValidSignature(signature, for: data)
} catch {
SecureLogger.logError(error, context: "Failed to verify signature", category: SecureLogger.noise)
SecureLogger.error(error, context: "Failed to verify signature")
return false
}
}
@@ -392,17 +391,17 @@ class NoiseEncryptionService {
// Validate peer ID
guard NoiseSecurityValidator.validatePeerID(peerID) else {
SecureLogger.logSecurityEvent(.authenticationFailed(peerID: peerID), level: .warning)
SecureLogger.warning(.authenticationFailed(peerID: peerID))
throw NoiseSecurityError.invalidPeerID
}
// Check rate limit
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
}
SecureLogger.logSecurityEvent(.handshakeStarted(peerID: peerID))
SecureLogger.info(.handshakeStarted(peerID: peerID))
// Return raw handshake data without wrapper
// The Noise protocol handles its own message format
@@ -415,19 +414,19 @@ class NoiseEncryptionService {
// Validate peer ID
guard NoiseSecurityValidator.validatePeerID(peerID) else {
SecureLogger.logSecurityEvent(.authenticationFailed(peerID: peerID), level: .warning)
SecureLogger.warning(.authenticationFailed(peerID: peerID))
throw NoiseSecurityError.invalidPeerID
}
// Validate message size
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
}
// Check rate limit
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
}
@@ -521,7 +520,7 @@ class NoiseEncryptionService {
peerFingerprints.removeValue(forKey: peerID)
}
SecureLogger.logSecurityEvent(.sessionExpired(peerID: peerID))
SecureLogger.info(.sessionExpired(peerID: peerID))
}
// MARK: - Private Helpers
@@ -537,7 +536,7 @@ class NoiseEncryptionService {
}
// Log security event
SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID))
SecureLogger.info(.handshakeCompleted(peerID: peerID))
// Notify all handlers about authentication
serviceQueue.async { [weak self] in
@@ -573,12 +572,12 @@ class NoiseEncryptionService {
// Attempt to rekey the session
do {
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
onHandshakeRequired?(peerID)
} 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
guard let recipientNpub = resolveRecipientNpub(for: peerID) 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))",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.prefix(8))… id=\(messageID.prefix(8))", category: .session)
// Convert recipient npub -> hex (x-only)
let recipientHex: String
do {
let (hrp, data) = try Bech32.decode(recipientNpub)
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
}
recipientHex = data.hexEncodedString()
} 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
}
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
}
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
}
SecureLogger.log("NostrTransport: sending PM giftWrap id=\(event.id.prefix(16))",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("NostrTransport: sending PM giftWrap id=\(event.id.prefix(16))", category: .session)
NostrRelayManager.shared.sendEvent(event)
}
}
@@ -99,8 +97,7 @@ final class NostrTransport: Transport {
Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: item.peerID) 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))",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))", category: .session)
// Convert recipient npub -> hex
let recipientHex: String
do {
@@ -109,15 +106,14 @@ final class NostrTransport: Transport {
recipientHex = data.hexEncodedString()
} catch { scheduleNextReadAck(); return }
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
}
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
}
SecureLogger.log("NostrTransport: sending READ ack giftWrap id=\(event.id.prefix(16))",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("NostrTransport: sending READ ack giftWrap id=\(event.id.prefix(16))", category: .session)
NostrRelayManager.shared.sendEvent(event)
scheduleNextReadAck()
}
@@ -136,8 +132,7 @@ final class NostrTransport: Transport {
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
SecureLogger.log("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))", category: .session)
// Convert recipient npub -> hex
let recipientHex: String
do {
@@ -146,15 +141,14 @@ final class NostrTransport: Transport {
recipientHex = data.hexEncodedString()
} catch { return }
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
}
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
}
SecureLogger.log("NostrTransport: sending favorite giftWrap id=\(event.id.prefix(16))",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("NostrTransport: sending favorite giftWrap id=\(event.id.prefix(16))", category: .session)
NostrRelayManager.shared.sendEvent(event)
}
}
@@ -180,8 +174,7 @@ final class NostrTransport: Transport {
Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: peerID) 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))",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))", category: .session)
let recipientHex: String
do {
let (hrp, data) = try Bech32.decode(recipientNpub)
@@ -189,15 +182,14 @@ final class NostrTransport: Transport {
recipientHex = data.hexEncodedString()
} catch { return }
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
}
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
}
SecureLogger.log("NostrTransport: sending DELIVERED ack giftWrap id=\(event.id.prefix(16))",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("NostrTransport: sending DELIVERED ack giftWrap id=\(event.id.prefix(16))", category: .session)
NostrRelayManager.shared.sendEvent(event)
}
}
@@ -205,8 +197,7 @@ final class NostrTransport: Transport {
// MARK: - Geohash ACK helpers
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
Task { @MainActor in
SecureLogger.log("GeoDM: send DELIVERED -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("GeoDM: send DELIVERED -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))", category: .session)
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 }
NostrRelayManager.registerPendingGiftWrap(id: event.id)
@@ -216,8 +207,7 @@ final class NostrTransport: Transport {
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
Task { @MainActor in
SecureLogger.log("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))", category: .session)
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 }
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) {
Task { @MainActor in
guard !recipientHex.isEmpty else { return }
SecureLogger.log("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))", category: .session)
// Build embedded BitChat packet without recipient peer ID
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
}
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
}
SecureLogger.log("NostrTransport: sending geohash PM giftWrap id=\(event.id.prefix(16))",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("NostrTransport: sending geohash PM giftWrap id=\(event.id.prefix(16))", category: .session)
NostrRelayManager.registerPendingGiftWrap(id: event.id)
NostrRelayManager.shared.sendEvent(event)
}
+1 -1
View File
@@ -14,7 +14,7 @@ import UIKit
import AppKit
#endif
class NotificationService {
final class NotificationService {
static let shared = NotificationService()
private init() {}
+2 -3
View File
@@ -10,7 +10,7 @@ import Foundation
import SwiftUI
/// Manages all private chat functionality
class PrivateChatManager: ObservableObject {
final class PrivateChatManager: ObservableObject {
@Published var privateChats: [String: [BitchatMessage]] = [:]
@Published var selectedPeer: String? = nil
@Published var unreadMessages: Set<String> = []
@@ -228,8 +228,7 @@ class PrivateChatManager: ObservableObject {
// Route via MessageRouter to avoid handshakeRequired spam when session isn't established
if let router = messageRouter {
SecureLogger.log("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.prefix(8))… via router",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.prefix(8))… via router", category: .session)
Task { @MainActor in
router.sendReadReceipt(receipt, to: senderPeerID)
}
+13 -13
View File
@@ -189,7 +189,7 @@ final class TorManager: ObservableObject {
var started = false
// If already running (per C glue), treat as started
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
}
dir.withCString { dptr in
@@ -198,9 +198,9 @@ final class TorManager: ObservableObject {
let rc = tor_host_start(dptr, sptr, cptr, 1)
started = (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 {
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 {
self.socksReady = 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 {
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.
private func startTorViaDlopen() -> Bool {
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
}
// Load the library
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 {
let err = String(cString: dlerror())
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])
}
// 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)
DispatchQueue.global(qos: .utility).async {
// Build stable C argv in this thread
@@ -339,9 +339,9 @@ final class TorManager: ObservableObject {
self.socksReady = ready
if !ready {
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 {
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%
}
@@ -385,7 +385,7 @@ final class TorManager: ObservableObject {
var argv: [String] = ["tor"]
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)
DispatchQueue.global(qos: .utility).async {
// Build stable C argv in this thread
@@ -409,10 +409,10 @@ final class TorManager: ObservableObject {
await MainActor.run {
self.socksReady = 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 {
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%
}
+2
View File
@@ -37,6 +37,8 @@ enum TransportConfig {
// UI thresholds
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 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
@MainActor
class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
// MARK: - Published Properties
@@ -195,31 +195,6 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
let favoriteStatus = favorites[noiseKey] {
peer.favoriteStatus = favoriteStatus
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
@@ -282,8 +257,7 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
/// Toggle favorite status
func toggleFavorite(_ peerID: String) {
guard let peer = getPeer(by: peerID) else {
SecureLogger.log("⚠️ Cannot toggle favorite - peer not found: \(peerID)",
category: SecureLogger.session, level: .warning)
SecureLogger.warning("⚠️ Cannot toggle favorite - peer not found: \(peerID)", category: .session)
return
}
@@ -293,15 +267,13 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
var actualNickname = peer.nickname
// Debug logging to understand the issue
SecureLogger.log("🔍 Toggle favorite - peer.nickname: '\(peer.nickname)', peer.displayName: '\(peer.displayName)', peerID: \(peerID)",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("🔍 Toggle favorite - peer.nickname: '\(peer.nickname)', peer.displayName: '\(peer.displayName)', peerID: \(peerID)", category: .session)
if actualNickname.isEmpty {
// Try to get from mesh service's current peer list
if let meshPeerNickname = meshService.peerNickname(peerID: peerID) {
actualNickname = meshPeerNickname
SecureLogger.log("🔍 Got nickname from mesh service: '\(actualNickname)'",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("🔍 Got nickname from mesh service: '\(actualNickname)'", category: .session)
}
}
@@ -328,8 +300,7 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
}
// Log the final nickname being saved
SecureLogger.log("⭐️ Toggled favorite for '\(finalNickname)' (peerID: \(peerID), was: \(wasFavorite), now: \(!wasFavorite))",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("⭐️ Toggled favorite for '\(finalNickname)' (peerID: \(peerID), was: \(wasFavorite), now: \(!wasFavorite))", category: .session)
// Send favorite notification to the peer via router (mesh or Nostr)
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
/// Provides safe logging that filters sensitive data and security events
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")
final class SecureLogger {
// MARK: - Timestamp Formatter
@@ -124,24 +113,90 @@ class SecureLogger {
// MARK: - Public Logging Methods
/// Log a security event
static func logSecurityEvent(_ event: SecurityEvent, level: LogLevel = .info,
file: String = #file, line: Int = #line, function: String = #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 debug(_ message: @autoclosure () -> String, category: OSLog = .noise,
file: String = #file, line: Int = #line, function: String = #function) {
log(message(), category: category, level: .debug, file: file, line: line, function: function)
}
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
static func log(_ message: @autoclosure () -> String, category: OSLog = noise, level: LogLevel = .debug,
file: String = #file, line: Int = #line, function: String = #function) {
static func log(_ message: @autoclosure () -> String, category: OSLog, level: LogLevel,
file: String, line: Int, function: String) {
guard shouldLog(level) else { return }
let location = formatLocation(file: file, line: line, function: function)
let sanitized = sanitize("\(location) \(message())")
@@ -156,31 +211,30 @@ class SecureLogger {
#endif
}
/// Log errors with context
static func logError(_ error: Error, context: @autoclosure () -> String, category: OSLog = noise,
file: String = #file, line: Int = #line, function: String = #function) {
/// Log a security event
static func logSecurityEvent(_ event: SecurityEvent, level: LogLevel = .info,
file: String, line: Int, function: String) {
guard shouldLog(level) else { return }
let location = formatLocation(file: file, line: line, function: function)
let sanitized = sanitize(context())
let errorDesc = sanitize(error.localizedDescription)
let message = "\(location) \(event.message)"
#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
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
}
// MARK: - Private Helpers
/// 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 timestamp = timestampFormatter.string(from: Date())
return "[\(timestamp)] [\(fileName):\(line) \(function)]"
}
/// 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
// Check cache first
@@ -226,45 +280,12 @@ class SecureLogger {
}
/// Sanitize individual values
private static func sanitize<T>(_ value: T) -> String {
static func sanitize<T>(_ value: T) -> String {
let stringValue = String(describing: value)
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
/// 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) {
#if DEBUG
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
}
+150 -161
View File
@@ -90,7 +90,7 @@ import UIKit
/// Manages the application state and business logic for BitChat.
/// Acts as the primary coordinator between UI components and backend services,
/// implementing the BitchatDelegate protocol to handle network events.
class ChatViewModel: ObservableObject, BitchatDelegate {
final class ChatViewModel: ObservableObject, BitchatDelegate {
// Precompiled regexes and detectors reused across formatting
private enum Regexes {
static let hashtag: NSRegularExpression = {
@@ -420,6 +420,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
private var geoSamplingSubs: [String: String] = [:] // subID -> geohash
private var lastGeoNotificationAt: [String: Date] = [:] // geohash -> last notify time
// MARK: - Message Delivery Tracking
// Delivery tracking
@@ -462,8 +463,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if let data = try? JSONEncoder().encode(Array(sentReadReceipts)) {
UserDefaults.standard.set(data, forKey: "sentReadReceipts")
} else {
SecureLogger.log("❌ Failed to encode read receipts for persistence",
category: SecureLogger.session, level: .error)
SecureLogger.error("❌ Failed to encode read receipts for persistence", category: .session)
}
}
}
@@ -562,11 +562,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Wait for Tor readiness before Nostr init
let ready = await TorManager.shared.awaitReady(timeout: 60)
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
}
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
// Attempt to flush any queued outbox after Nostr comes online
@@ -596,8 +596,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if uniquePeers[peer.id] == nil {
uniquePeers[peer.id] = peer
} else {
SecureLogger.log("⚠️ Duplicate peer ID detected: \(peer.id) (\(peer.displayName))",
category: SecureLogger.session, level: .warning)
SecureLogger.warning("⚠️ Duplicate peer ID detected: \(peer.id) (\(peer.displayName))", category: .session)
}
}
self.peerIndex = uniquePeers
@@ -899,7 +898,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if Date().timeIntervalSince(eventTime) < 15 { return }
}
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
}
// 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 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 msg = BitchatMessage(
id: event.id,
@@ -1035,11 +1036,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if let idx = self.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) {
self.privateChats[convKey]?[idx].deliveryStatus = .delivered(to: self.displayNameForNostrPubkey(senderPubkey), at: Date())
self.objectWillChange.send()
SecureLogger.log("GeoDM: recv DELIVERED for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))",
category: SecureLogger.session, level: .info)
SecureLogger.info("GeoDM: recv DELIVERED for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))", category: .session)
} else {
SecureLogger.log("GeoDM: delivered ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)",
category: SecureLogger.session, level: .warning)
SecureLogger.warning("GeoDM: delivered ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session)
}
}
case .readReceipt:
@@ -1047,11 +1046,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if let idx = self.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) {
self.privateChats[convKey]?[idx].deliveryStatus = .read(by: self.displayNameForNostrPubkey(senderPubkey), at: Date())
self.objectWillChange.send()
SecureLogger.log("GeoDM: recv READ for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))",
category: SecureLogger.session, level: .info)
SecureLogger.info("GeoDM: recv READ for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))", category: .session)
} else {
SecureLogger.log("GeoDM: read ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)",
category: SecureLogger.session, level: .warning)
SecureLogger.warning("GeoDM: read ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session)
}
}
case .verifyChallenge, .verifyResponse:
@@ -1464,14 +1461,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
count: TransportConfig.nostrGeoRelayCount
)
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 {
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
}
// Track ourselves as active participant
self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
SecureLogger.log("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(LocationChannelManager.shared.teleported)",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(LocationChannelManager.shared.teleported)", category: .session)
// 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)
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
@@ -1479,11 +1475,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if LocationChannelManager.shared.teleported && hasRegional && !inRegional {
let key = identity.publicKeyHex.lowercased()
self.teleportedGeo = self.teleportedGeo.union([key])
SecureLogger.log("GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(self.teleportedGeo.count)",
category: SecureLogger.session, level: .info)
SecureLogger.info("GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(self.teleportedGeo.count)", category: .session)
}
} 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")
}
}
@@ -1510,7 +1505,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Debug: log if any empty messages are present
let emptyMesh = messages.filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count
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()
geohashPeople = []
@@ -1537,7 +1532,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Debug: log if any empty messages are present post-sanitize
let emptyGeo = messages.filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count
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
@@ -1569,8 +1564,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let key = id.publicKeyHex.lowercased()
if LocationChannelManager.shared.teleported && hasRegional && !inRegional {
teleportedGeo = teleportedGeo.union([key])
SecureLogger.log("GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)",
category: SecureLogger.session, level: .info)
SecureLogger.info("GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", category: .session)
} else {
teleportedGeo.remove(key)
}
@@ -1593,8 +1587,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
self.recordProcessedEvent(event.id)
// Log incoming tags for diagnostics
let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",")
SecureLogger.log("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)", category: .session)
// Track teleport tag for participants only our format ["t", "teleport"]
let hasTeleportTag: Bool = event.tags.contains(where: { tag in
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
@@ -1611,8 +1604,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if !isSelf {
Task { @MainActor in
self.teleportedGeo = self.teleportedGeo.union([key])
SecureLogger.log("GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(self.teleportedGeo.count)",
category: SecureLogger.session, level: .info)
SecureLogger.info("GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(self.teleportedGeo.count)", category: .session)
}
}
}
@@ -1625,7 +1617,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}
// Cache nickname from tag if present
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
}
// If this pubkey is blocked, skip mapping, participants, and timeline
@@ -1647,7 +1639,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
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 msg = BitchatMessage(
id: event.id,
@@ -1676,8 +1670,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// pared back logging: subscribe debug only
// Log GeoDM subscribe only when Tor is ready to avoid early noise
if TorManager.shared.isReady {
SecureLogger.log("GeoDM: subscribing DMs pub=\(id.publicKeyHex.prefix(8))… sub=\(dmSub)",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("GeoDM: subscribing DMs pub=\(id.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session)
}
let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds))
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
@@ -1687,12 +1680,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
self.recordProcessedEvent(giftWrap.id)
// Decrypt with per-geohash identity
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id) else {
SecureLogger.log("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))",
category: SecureLogger.session, level: .warning)
SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))", category: .session)
return
}
SecureLogger.log("GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...", category: .session)
guard content.hasPrefix("bitchat1:") else { return }
guard let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
let packet = BitchatPacket.from(packetData) else { return }
@@ -1705,8 +1696,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
case .privateMessage:
guard let pm = PrivateMessagePacket.decode(from: noisePayload.data) else { return }
let messageId = pm.messageID
SecureLogger.log("GeoDM: recv PM <- sender=\(senderPubkey.prefix(8))… mid=\(messageId.prefix(8))",
category: SecureLogger.session, level: .info)
SecureLogger.info("GeoDM: recv PM <- sender=\(senderPubkey.prefix(8))… mid=\(messageId.prefix(8))", category: .session)
// Send delivery ACK immediately (even if duplicate), once per messageID
if !self.sentGeoDeliveryAcks.contains(messageId) {
let nostrTransport = NostrTransport()
@@ -1769,11 +1759,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if let idx = self.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) {
self.privateChats[convKey]?[idx].deliveryStatus = .delivered(to: self.displayNameForNostrPubkey(senderPubkey), at: Date())
self.objectWillChange.send()
SecureLogger.log("GeoDM: recv DELIVERED for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))",
category: SecureLogger.session, level: .info)
SecureLogger.info("GeoDM: recv DELIVERED for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))", category: .session)
} else {
SecureLogger.log("GeoDM: delivered ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)",
category: SecureLogger.session, level: .warning)
SecureLogger.warning("GeoDM: delivered ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session)
}
}
case .readReceipt:
@@ -1781,11 +1769,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if let idx = self.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) {
self.privateChats[convKey]?[idx].deliveryStatus = .read(by: self.displayNameForNostrPubkey(senderPubkey), at: Date())
self.objectWillChange.send()
SecureLogger.log("GeoDM: recv READ for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))",
category: SecureLogger.session, level: .info)
SecureLogger.info("GeoDM: recv READ for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))", category: .session)
} else {
SecureLogger.log("GeoDM: read ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)",
category: SecureLogger.session, level: .warning)
SecureLogger.warning("GeoDM: read ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session)
}
}
default:
@@ -2019,7 +2005,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let senderSuffix = String(event.pubkey.suffix(4))
let nick = self.geoNicknames[event.pubkey.lowercased()]
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 msg = BitchatMessage(
id: event.id,
@@ -2152,8 +2140,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}
return
}
SecureLogger.log("GeoDM: local send mid=\(messageID.prefix(8))… to=\(recipientHex.prefix(8))… conv=\(peerID)",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("GeoDM: local send mid=\(messageID.prefix(8))… to=\(recipientHex.prefix(8))… conv=\(peerID)", category: .session)
let nostrTransport = NostrTransport()
nostrTransport.senderPeerID = meshService.myPeerID
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 hadUnreadTemp {
unreadPrivateMessages.insert(peerID)
SecureLogger.log("📬 Transferred unread status from temp peer IDs to \(peerID)",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("📬 Transferred unread status from temp peer IDs to \(peerID)", category: .session)
}
if consolidatedCount > 0 {
// Sort by timestamp
privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
SecureLogger.log("📥 Consolidated \(consolidatedCount) Nostr messages from temporary peer IDs to \(peerNickname)",
category: SecureLogger.session, level: .info)
SecureLogger.info("📥 Consolidated \(consolidatedCount) Nostr messages from temporary peer IDs to \(peerNickname)", category: .session)
}
}
@@ -2496,8 +2481,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
break
}
} else {
SecureLogger.log("GeoDM: skipping mesh handshake for virtual peerID=\(peerID)",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("GeoDM: skipping mesh handshake for virtual peerID=\(peerID)", category: .session)
}
// 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) {
guard let receipt = notification.userInfo?["receipt"] as? ReadReceipt else { return }
SecureLogger.log("📖 Handling read receipt for message \(receipt.originalMessageID) from Nostr",
category: SecureLogger.session, level: .info)
SecureLogger.info("📖 Handling read receipt for message \(receipt.originalMessageID) from Nostr", category: .session)
// Process the read receipt through the same flow as Bluetooth read receipts
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 selectedPrivateChatPeer == oldPeerID {
SecureLogger.log("📱 Updating private chat peer ID due to key change: \(oldPeerID) -> \(newPeerID)",
category: SecureLogger.session, level: .info)
SecureLogger.info("📱 Updating private chat peer ID due to key change: \(oldPeerID) -> \(newPeerID)", category: .session)
// Transfer private chat messages to new peer ID
if let messages = privateChats[oldPeerID] {
@@ -2635,8 +2617,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} else {
// Even if the chat isn't open, migrate any existing private chat data
if let messages = privateChats[oldPeerID] {
SecureLogger.log("📱 Migrating private chat messages from \(oldPeerID) to \(newPeerID)",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("📱 Migrating private chat messages from \(oldPeerID) to \(newPeerID)", category: .session)
var chats = privateChats
chats[newPeerID] = messages
chats.removeValue(forKey: oldPeerID)
@@ -2742,7 +2723,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
messageRouter.sendPrivate(screenshotMessage, to: peerID, recipientNickname: peerNickname, messageID: UUID().uuidString)
default:
// 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)
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 {
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
}
// Track ourselves as active participant
self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
} 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")
}
}
@@ -2848,8 +2829,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Search for the current peer ID with the same nickname
for (currentPeerID, currentNickname) in meshService.getPeerNicknames() {
if currentNickname == peerNickname {
SecureLogger.log("📖 Resolved updated peer ID for read receipt: \(peerID) -> \(currentPeerID)",
category: SecureLogger.session, level: .info)
SecureLogger.info("📖 Resolved updated peer ID for read receipt: \(peerID) -> \(currentPeerID)", category: .session)
actualPeerID = currentPeerID
break
}
@@ -2877,8 +2857,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let messages = privateChats[peerID] ?? []
for message in messages where message.senderPeerID == peerID && !message.isRelay {
if !sentReadReceipts.contains(message.id) {
SecureLogger.log("GeoDM: sending READ for mid=\(message.id.prefix(8))… to=\(recipientHex.prefix(8))",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("GeoDM: sending READ for mid=\(message.id.prefix(8))… to=\(recipientHex.prefix(8))", category: .session)
let nostrTransport = NostrTransport()
nostrTransport.senderPeerID = meshService.myPeerID
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
switch LocationChannelManager.shared.selectedChannel {
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 = {
if let hashIndex = nickname.firstIndex(of: "#") { return String(nickname[..<hashIndex]) }
return nickname
@@ -4322,14 +4309,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
verifiedFingerprints = SecureIdentityStateManager.shared.getVerifiedFingerprints()
// Log snapshot for debugging persistence
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
let offlineFavorites = unifiedPeerService.favorites.filter { !$0.isConnected }
for fav in offlineFavorites {
let fp = unifiedPeerService.getFingerprint(for: fav.id)
let isVer = fp.flatMap { verifiedFingerprints.contains($0) } ?? false
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
invalidateEncryptionCache()
@@ -4345,7 +4332,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
DispatchQueue.main.async {
guard let self = self else { return }
SecureLogger.log("🔐 Authenticated: \(peerID)", category: SecureLogger.security, level: .debug)
SecureLogger.debug("🔐 Authenticated: \(peerID)", category: .security)
// Update encryption status
if self.verifiedFingerprints.contains(fingerprint) {
@@ -4364,8 +4351,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let keyData = self.meshService.getNoiseService().getPeerPublicKeyData(peerID) {
let stable = keyData.hexEncodedString()
self.shortIDToNoiseKey[peerID] = stable
SecureLogger.log("🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stable.prefix(8))",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stable.prefix(8))", category: .session)
}
// 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)
pending.sent = true
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
@@ -4519,7 +4505,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
pendingQRVerifications.removeValue(forKey: peerID)
if let fp = getFingerprint(for: peerID) {
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.forceSave()
verifiedFingerprints.insert(fp)
@@ -4605,7 +4591,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// MARK: - Peer Connection Events
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
Task { @MainActor in
@@ -4614,17 +4600,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Register ephemeral session with identity manager
SecureIdentityStateManager.shared.registerEphemeralSession(peerID: peerID)
// Check if we favorite this peer and resend notification on reconnect
// This ensures Nostr key mapping is maintained across reconnections
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)
}
// Intentionally do not resend favorites on reconnect.
// We only send our npub when a favorite is toggled on, or if our npub changes.
// Force UI refresh
objectWillChange.send()
@@ -4643,7 +4620,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}
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
SecureIdentityStateManager.shared.removeEphemeralSession(peerID: peerID)
@@ -4738,8 +4715,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
self.lastNetworkNotificationTime = Date()
self.recentlySeenPeers = currentPeerSet
NotificationService.shared.sendNetworkAvailableNotification(peerCount: meshPeers.count)
SecureLogger.log("👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers",
category: SecureLogger.session, level: .info)
SecureLogger.info("👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers", category: .session)
}
} else {
// No peers immediately reset to allow next rising-edge to notify
@@ -4749,7 +4725,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
self.networkResetTimer?.invalidate()
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
@@ -4811,8 +4787,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}
if !idsToRemove.isEmpty {
SecureLogger.log("🧹 Cleaned up \(idsToRemove.count) stale unread peer IDs",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("🧹 Cleaned up \(idsToRemove.count) stale unread peer IDs", category: .session)
}
}
@@ -4842,8 +4817,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let removedCount = oldCount - sentReadReceipts.count
if removedCount > 0 {
SecureLogger.log("🧹 Cleaned up \(removedCount) old read receipts",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("🧹 Cleaned up \(removedCount) old read receipts", category: .session)
}
}
@@ -4891,7 +4865,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
nostrPublicKey = data.hexEncodedString()
}
} 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)
}
/// 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.
/// Also persists the message into the active channel's backing store so it survives timeline rebinds.
@MainActor
@@ -5032,7 +5026,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
}
} 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
@@ -5048,12 +5042,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor
private func setupNostrMessageHandling() {
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
}
SecureLogger.log("🔑 Setting up Nostr subscription for pubkey: \(currentIdentity.publicKeyHex.prefix(16))...",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("🔑 Setting up Nostr subscription for pubkey: \(currentIdentity.publicKeyHex.prefix(16))...", category: .session)
// Subscribe to Nostr messages
let filter = NostrFilter.giftWrapsFor(
@@ -5093,19 +5086,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Expect embedded BitChat packet content
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
}
guard let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
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
}
// Only process typed noiseEncrypted envelope for private messages/receipts
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
}
@@ -5119,7 +5112,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Parse plaintext typed payload
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
}
@@ -5215,14 +5208,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Send delivery ack via Nostr embedded
if !wasReadBefore {
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())
} else if let id = try? NostrIdentityBridge.getCurrentNostrIdentity() {
// Fallback: no Noise mapping yet send directly to sender's Nostr pubkey
let nt = NostrTransport()
nt.senderPeerID = meshService.myPeerID
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 let key = actualSenderNoiseKey {
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())
sentReadReceipts.insert(messageId)
} else if let id = try? NostrIdentityBridge.getCurrentNostrIdentity() {
@@ -5245,7 +5238,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
nt.senderPeerID = meshService.myPeerID
nt.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubkey, from: id)
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 {
@@ -5290,7 +5283,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}
} 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"
let parts = content.split(separator: ":", maxSplits: 2)
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
}
@@ -5326,8 +5319,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}
processedNostrAcks.insert(ackKey)
SecureLogger.log("📨 Received \(ackType) ACK for message \(messageId.prefix(16))... from \(senderPubkey.prefix(16))...",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("📨 Received \(ackType) ACK for message \(messageId.prefix(16))... from \(senderPubkey.prefix(16))...", category: .session)
// Verify the sender has a valid Noise key
guard findNoiseKey(for: senderPubkey) != nil else {
@@ -5346,12 +5338,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
case "READ":
privateChats[chatPeerID]?[index].deliveryStatus = .read(by: "recipient", at: Date())
default:
SecureLogger.log("⚠️ Unknown ACK type: \(ackType)", category: SecureLogger.session, level: .warning)
SecureLogger.warning("⚠️ Unknown ACK type: \(ackType)", category: .session)
}
messageFound = true
SecureLogger.log("✅ Updated message \(messageId.prefix(16))... status to \(ackType) in chat \(chatPeerID.prefix(16))...",
category: SecureLogger.session, level: .info)
SecureLogger.info("✅ Updated message \(messageId.prefix(16))... status to \(ackType) in chat \(chatPeerID.prefix(16))...", category: .session)
// Don't break - continue to update in all chats where this message exists
}
}
@@ -5359,8 +5350,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if messageFound {
objectWillChange.send()
} else {
SecureLogger.log("⚠️ Could not find message \(messageId) to update status from ACK",
category: SecureLogger.session, level: .warning)
SecureLogger.warning("⚠️ Could not find message \(messageId) to update status from ACK", category: .session)
}
}
@@ -5386,8 +5376,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
var nostrPubkey: String? = nil
if parts.count > 1 {
nostrPubkey = String(parts[1])
SecureLogger.log("📝 Received Nostr npub in favorite notification: \(nostrPubkey ?? "none")",
category: SecureLogger.session, level: .info)
SecureLogger.info("📝 Received Nostr npub in favorite notification: \(nostrPubkey ?? "none")", category: .session)
}
// Get the noise public key for this peer
@@ -5405,12 +5394,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}
guard let finalNoiseKey = noiseKey else {
SecureLogger.log("⚠️ Cannot get Noise key for peer \(peerID)",
category: SecureLogger.session, level: .warning)
SecureLogger.warning("⚠️ Cannot get Noise key for peer \(peerID)", category: .session)
return
}
// Determine prior state to avoid duplicate system messages on repeated notifications
let prior = FavoritesPersistenceService.shared.getFavoriteStatus(for: finalNoiseKey)?.theyFavoritedUs ?? false
// Update the favorite relationship
// Update the favorite relationship (idempotent storage)
FavoritesPersistenceService.shared.updatePeerFavoritedUs(
peerNoisePublicKey: finalNoiseKey,
favorited: isFavorite,
@@ -5418,15 +5408,16 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
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 {
SecureLogger.log("💾 Storing Nostr key association for \(senderNickname): \(nostrPubkey!.prefix(16))...",
category: SecureLogger.session, level: .info)
SecureLogger.info("💾 Storing Nostr key association for \(senderNickname): \(nostrPubkey!.prefix(16))...", category: .session)
}
// Show system message
let action = isFavorite ? "favorited" : "unfavorited"
addSystemMessage("\(senderNickname) \(action) you")
// 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")
}
}
@MainActor
@@ -5532,8 +5523,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Not notifying for old message
}
SecureLogger.log("📬 Stored Nostr message from unknown sender \(finalSenderNickname) in temporary peer \(tempPeerID)",
category: SecureLogger.session, level: .info)
SecureLogger.info("📬 Stored Nostr message from unknown sender \(finalSenderNickname) in temporary peer \(tempPeerID)", category: .session)
}
@MainActor
@@ -5545,16 +5535,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} else {
// Try to convert hex to npub
guard let pubkeyData = Data(hexString: nostrPubkey) else {
SecureLogger.log("⚠️ Invalid hex public key format: \(nostrPubkey.prefix(16))...",
category: SecureLogger.session, level: .warning)
SecureLogger.warning("⚠️ Invalid hex public key format: \(nostrPubkey.prefix(16))...", category: .session)
return nil
}
do {
npubToMatch = try Bech32.encode(hrp: "npub", data: pubkeyData)
} catch {
SecureLogger.log("⚠️ Failed to convert hex to npub: \(error)",
category: SecureLogger.session, level: .warning)
SecureLogger.warning("⚠️ Failed to convert hex to npub: \(error)", category: .session)
return nil
}
}
@@ -5564,22 +5552,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if let storedNostrKey = relationship.peerNostrPublicKey {
// Compare npub format
if storedNostrKey == npubToMatch {
// SecureLogger.log(" Found Noise key for Nostr sender (npub match)",
// category: SecureLogger.session, level: .debug)
// SecureLogger.debug(" Found Noise key for Nostr sender (npub match)", category: .session)
return noiseKey
}
// Also try hex comparison if stored value is hex
if !storedNostrKey.hasPrefix("npub") && storedNostrKey == nostrPubkey {
SecureLogger.log("✅ Found Noise key for Nostr sender (hex match)",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("✅ Found Noise key for Nostr sender (hex match)", category: .session)
return noiseKey
}
}
}
SecureLogger.log("⚠️ No matching Noise key found for Nostr pubkey: \(nostrPubkey.prefix(16))... (tried npub: \(npubToMatch.prefix(16))...)",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("⚠️ No matching Noise key found for Nostr pubkey: \(nostrPubkey.prefix(16))... (tried npub: \(npubToMatch.prefix(16))...)", category: .session)
return nil
}
@@ -5607,13 +5592,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Try mesh first for connected peers
if meshService.isPeerConnected(peerID) {
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 {
// Send via Nostr for offline peers (using router)
let recipientPeerID = key.hexEncodedString()
messageRouter.sendFavoriteNotification(to: recipientPeerID, isFavorite: isFavorite)
} 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)
} else {
// Keep old messages in original location but don't show in UI
SecureLogger.log("📦 Partially migrating \(recentMessages.count) of \(messages.count) messages from \(oldPeerID)",
category: SecureLogger.session, level: .info)
SecureLogger.info("📦 Partially migrating \(recentMessages.count) of \(messages.count) messages from \(oldPeerID)", category: .session)
}
SecureLogger.log("📦 Migrating \(recentMessages.count) recent messages from old peer ID \(oldPeerID) to \(peerID) (fingerprint match)",
category: SecureLogger.session, level: .info)
SecureLogger.info("📦 Migrating \(recentMessages.count) recent messages from old peer ID \(oldPeerID) to \(peerID) (fingerprint match)", category: .session)
} else if currentFingerprint == nil || oldFingerprint == nil {
// Check if this chat contains messages with this sender by nickname
let isRelevantChat = recentMessages.contains { msg in
@@ -5724,8 +5707,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
oldPeerIDsToRemove.append(oldPeerID)
}
SecureLogger.log("📦 Migrating \(recentMessages.count) recent messages from old peer ID \(oldPeerID) to \(peerID) (nickname match)",
category: SecureLogger.session, level: .warning)
SecureLogger.warning("📦 Migrating \(recentMessages.count) recent messages from old peer ID \(oldPeerID) to \(peerID) (nickname match)", category: .session)
}
}
}
@@ -5764,8 +5746,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Update selectedPrivateChatPeer if it was pointing to an old ID
if needsSelectedUpdate {
selectedPrivateChatPeer = peerID
SecureLogger.log("📱 Updated selectedPrivateChatPeer from old ID to \(peerID) during migration",
category: SecureLogger.session, level: .info)
SecureLogger.info("📱 Updated selectedPrivateChatPeer from old ID to \(peerID) during migration", category: .session)
}
}
}
@@ -5774,11 +5755,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
/// Handle incoming private message
@MainActor
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)
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
}
@@ -5818,8 +5799,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Clean up the stable key storage to avoid duplication
privateChats.removeValue(forKey: stableKeyHex)
SecureLogger.log("📥 Consolidated \(nostrMessages.count) Nostr messages from stable key to ephemeral peer \(peerID)",
category: SecureLogger.session, level: .info)
SecureLogger.info("📥 Consolidated \(nostrMessages.count) Nostr messages from stable key to ephemeral peer \(peerID)", category: .session)
}
}
}
@@ -6021,10 +6001,20 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
isBatchingPublic = true
// Rough chronological order: sort the batch by timestamp before inserting
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
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)
if idx >= messages.count { messages.append(m) } else { messages.insert(m, at: idx) }
} else {
@@ -6086,8 +6076,7 @@ private func checkForMentions(_ message: BitchatMessage) {
let isMentioned = (message.mentions?.contains { myTokens.contains($0) } ?? false)
if isMentioned && message.sender != nickname {
SecureLogger.log("🔔 Mention from \(message.sender)",
category: SecureLogger.session, level: .info)
SecureLogger.info("🔔 Mention from \(message.sender)", category: .session)
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 peerID = id.removingPercentEncoding ?? id
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
}
.onOpenURL { url in
+1 -1
View File
@@ -270,7 +270,7 @@ final class BLEServiceTests: XCTestCase {
// MARK: - Mock Delegate Helper
private class MockBitchatDelegate: BitchatDelegate {
private final class MockBitchatDelegate: BitchatDelegate {
private let messageHandler: (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
/// simulate broadcast propagation across the mesh. E2E tests keep it off and perform explicit
/// relays when needed.
class MockBLEService: NSObject {
final class MockBLEService: NSObject {
// Enable automatic flooding for public messages in integration tests only
static var autoFloodEnabled: Bool = false
+1 -1
View File
@@ -10,7 +10,7 @@ import Foundation
import CryptoKit
@testable import bitchat
class TestHelpers {
final class TestHelpers {
// MARK: - Key Generation
+5
View File
@@ -32,6 +32,8 @@ targets:
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
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.
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:
- bluetooth-central
- bluetooth-peripheral
@@ -89,6 +91,8 @@ targets:
LSMinimumSystemVersion: $(MACOSX_DEPLOYMENT_TARGET)
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.
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:
- CFBundleURLSchemes:
- bitchat
@@ -126,6 +130,7 @@ targets:
platform: iOS
sources:
- bitchatShareExtension
- bitchat/Services/TransportConfig.swift
info:
path: bitchatShareExtension/Info.plist
properties: