Compare commits

..
Author SHA1 Message Date
jackandGitHub e455a23fe9 Revert "SPM Test target + Github Action to build and test (#585)"
This reverts commit 179663663b.
2025-09-13 10:19:45 +02:00
920dc31795 Refactor: Testable Keychain and Identity Manager (#584)
* Make static functions instance functions to be testable

* Injectable KeychainManager + Mock + updated tests

* Remove `pendingActions` from identity manager (dead code)

* Remove `getHandshakeState` from identity manager (dead code)

* Remove `getAllSocialIdentities` from identity manager (dead code)

* Remove `getCryptographicIdentity` from identity manager (dead code)

* Remove `resolveIdentity` from identity manager (dead code)

* Identity Manager: minor clean up

* Put Identity Manager behind a protocol

* Remove Keychain and Identity Manager singletons

* Tests: include MockKeychain/MockIdentityManager in project; init identityManager in CommandProcessorTests

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-09-12 14:37:34 +02:00
bb3d99bdca Fix repeated favorite notifications; route system messages to mesh; simplify favorites (#588)
* Favorites: mesh-only system message; stop reconnect resends; gate system on state change

* Favorites: remove npub resend tracking and nickname-based key migration; rely on Noise key as identity

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-09-12 14:36:32 +02:00
jackandGitHub b01cac4649 Delete Frameworks/README.md 2025-09-12 13:16: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
60 changed files with 1196 additions and 1063 deletions
+40
View File
@@ -0,0 +1,40 @@
name: Fetch GeoRelays Data
on:
schedule:
- cron: '0 6 * * 0'
workflow_dispatch:
permissions:
contents: write
jobs:
update-relay-data:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Fetch GeoRelays
run: |
wget https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
mv nostr_relays.csv ./relays/online_relays_gps.csv
- name: Check for changes
id: git-check
run: |
git diff --exit-code || echo "changes=true" >> $GITHUB_OUTPUT
- name: Commit and push changes
if: steps.git-check.outputs.changes == 'true'
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add relays/online_relays_gps.csv
git commit -m "Automated update of relay data - $(date -u)"
git push
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+3 -3
View File
@@ -37,7 +37,7 @@ This three-message pattern provides:
#### NoiseEncryptionService #### NoiseEncryptionService
The main service managing all Noise operations: The main service managing all Noise operations:
```swift ```swift
class NoiseEncryptionService { final class NoiseEncryptionService {
private let staticIdentityKey: Curve25519.KeyAgreement.PrivateKey private let staticIdentityKey: Curve25519.KeyAgreement.PrivateKey
private let sessionManager: NoiseSessionManager private let sessionManager: NoiseSessionManager
private let channelEncryption = NoiseChannelEncryption() private let channelEncryption = NoiseChannelEncryption()
@@ -47,7 +47,7 @@ class NoiseEncryptionService {
#### NoiseSession #### NoiseSession
Individual session state for each peer: Individual session state for each peer:
```swift ```swift
class NoiseSession { final class NoiseSession {
private var handshakeState: NoiseHandshakeState? private var handshakeState: NoiseHandshakeState?
private var sendCipher: NoiseCipherState? private var sendCipher: NoiseCipherState?
private var receiveCipher: NoiseCipherState? private var receiveCipher: NoiseCipherState?
@@ -58,7 +58,7 @@ class NoiseSession {
#### NoiseSessionManager #### NoiseSessionManager
Thread-safe session management: Thread-safe session management:
```swift ```swift
class NoiseSessionManager { final class NoiseSessionManager {
private var sessions: [String: NoiseSession] = [:] private var sessions: [String: NoiseSession] = [:]
private let sessionsQueue = DispatchQueue(label: "noise.sessions", attributes: .concurrent) private let sessionsQueue = DispatchQueue(label: "noise.sessions", attributes: .concurrent)
} }
-12
View File
@@ -1,12 +0,0 @@
Place Tor.xcframework here
Instructions
- Obtain a prebuilt Tor Apple xcframework (iCepa/Onion Browser lineage) or build your own minimal client-only Tor.
- Rename it (if needed) to `Tor.xcframework` and drop it in this `Frameworks/` directory.
- Regenerate the Xcode project if you use XcodeGen (`project.yml` already references `Frameworks/Tor.xcframework`).
- Build the app; `TorManager` will automatically bootstrap Tor and route all networking through it.
Notes
- For iOS, the framework will be embedded and code-signed automatically.
- For macOS, it will be linked and embedded as well (you may prefer a system tor for smaller bundles).
+32 -7
View File
@@ -39,10 +39,8 @@
0481A3592E6D929E00FC845E /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A3572E6D929E00FC845E /* tor-nolzma.xcframework */; }; 0481A3592E6D929E00FC845E /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A3572E6D929E00FC845E /* tor-nolzma.xcframework */; };
0481A35B2E6D9BEF00FC845E /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35A2E6D9BEF00FC845E /* libz.tbd */; }; 0481A35B2E6D9BEF00FC845E /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35A2E6D9BEF00FC845E /* libz.tbd */; };
0481A35D2E6DA18600FC845E /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35C2E6DA18600FC845E /* libz.tbd */; }; 0481A35D2E6DA18600FC845E /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35C2E6DA18600FC845E /* libz.tbd */; };
0C0EFA112E6EAAAA00ABCDEF /* CTorHost.c in Sources */ = {isa = PBXBuildFile; fileRef = 0C0EFA102E6EAAAA00ABCDEF /* CTorHost.c */; }; 0481A3902E734CAE00FC845E /* CommandProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A38F2E734CAE00FC845E /* CommandProcessorTests.swift */; };
0C0EFA122E6EAAAA00ABCDF0 /* CTorHost.c in Sources */ = {isa = PBXBuildFile; fileRef = 0C0EFA102E6EAAAA00ABCDEF /* CTorHost.c */; }; 0481A3912E734CAE00FC845E /* CommandProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A38F2E734CAE00FC845E /* CommandProcessorTests.swift */; };
0C0EFA162E6EAABB00ABCDF4 /* TorNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0EFA132E6EAABB00ABCDF1 /* TorNotifications.swift */; };
0C0EFA172E6EAABB00ABCDF5 /* TorNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0EFA132E6EAABB00ABCDF1 /* TorNotifications.swift */; };
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; }; 048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; }; 048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
048A4BE92E5CCCC300162C4B /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; }; 048A4BE92E5CCCC300162C4B /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
@@ -76,6 +74,10 @@
049BD3B52E51F319001A566B /* MessageRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3B02E51F319001A566B /* MessageRouter.swift */; }; 049BD3B52E51F319001A566B /* MessageRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3B02E51F319001A566B /* MessageRouter.swift */; };
0AE840940F21AFC07C226636 /* PrivateChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A262EDDC04B7D7B5E31F321 /* PrivateChatE2ETests.swift */; }; 0AE840940F21AFC07C226636 /* PrivateChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A262EDDC04B7D7B5E31F321 /* PrivateChatE2ETests.swift */; };
0B6F25559A21F8C69C8357C6 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */; }; 0B6F25559A21F8C69C8357C6 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */; };
0C0EFA112E6EAAAA00ABCDEF /* CTorHost.c in Sources */ = {isa = PBXBuildFile; fileRef = 0C0EFA102E6EAAAA00ABCDEF /* CTorHost.c */; };
0C0EFA122E6EAAAA00ABCDF0 /* CTorHost.c in Sources */ = {isa = PBXBuildFile; fileRef = 0C0EFA102E6EAAAA00ABCDEF /* CTorHost.c */; };
0C0EFA162E6EAABB00ABCDF4 /* TorNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0EFA132E6EAABB00ABCDF1 /* TorNotifications.swift */; };
0C0EFA172E6EAABB00ABCDF5 /* TorNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0EFA132E6EAABB00ABCDF1 /* TorNotifications.swift */; };
10E68BB889356219189E38EC /* BitchatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF625BB3AD919322C01A46B2 /* BitchatApp.swift */; }; 10E68BB889356219189E38EC /* BitchatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF625BB3AD919322C01A46B2 /* BitchatApp.swift */; };
1234567890ABCDEFFEDCBA13 /* PeerDisplayNameResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1234567890ABCDEFFEDCBA02 /* PeerDisplayNameResolver.swift */; }; 1234567890ABCDEFFEDCBA13 /* PeerDisplayNameResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1234567890ABCDEFFEDCBA02 /* PeerDisplayNameResolver.swift */; };
1234567890ABCDEFFEDCBA14 /* PeerDisplayNameResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1234567890ABCDEFFEDCBA02 /* PeerDisplayNameResolver.swift */; }; 1234567890ABCDEFFEDCBA14 /* PeerDisplayNameResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1234567890ABCDEFFEDCBA02 /* PeerDisplayNameResolver.swift */; };
@@ -166,9 +168,15 @@
EE8C3ECADAB3083A2687D50B /* NostrProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C272F137CE00FC5A96E0CC06 /* NostrProtocolTests.swift */; }; EE8C3ECADAB3083A2687D50B /* NostrProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C272F137CE00FC5A96E0CC06 /* NostrProtocolTests.swift */; };
EF49C600C1E464710DD6CA29 /* InputValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90CB7A5CD1D1A521CD31F380 /* InputValidator.swift */; }; EF49C600C1E464710DD6CA29 /* InputValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90CB7A5CD1D1A521CD31F380 /* InputValidator.swift */; };
F06732B1719EE13C5D09CE77 /* NostrProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */; }; F06732B1719EE13C5D09CE77 /* NostrProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */; };
F0A1B2C3D4E5F60718293A4B /* OSLog+Categories.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A1B2C3D4E5F60718293A4A /* OSLog+Categories.swift */; };
F0A1B2C3D4E5F60718293A4C /* OSLog+Categories.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A1B2C3D4E5F60718293A4A /* OSLog+Categories.swift */; };
F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */; }; F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */; };
FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136696FC4436A02D98CE6A77 /* KeychainManager.swift */; }; FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136696FC4436A02D98CE6A77 /* KeychainManager.swift */; };
FBC409E105493C491531B59A /* NostrProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */; }; FBC409E105493C491531B59A /* NostrProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */; };
A1B2C3D4E5F60123456789BA /* MockKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60123456789AA /* MockKeychain.swift */; };
A1B2C3D4E5F60123456789BB /* MockKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60123456789AA /* MockKeychain.swift */; };
A1B2C3D4E5F60123456789BC /* MockIdentityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60123456789AB /* MockIdentityManager.swift */; };
A1B2C3D4E5F60123456789BD /* MockIdentityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60123456789AB /* MockIdentityManager.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -224,12 +232,11 @@
047502B82E560F690083520F /* RelayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayController.swift; sourceTree = "<group>"; }; 047502B82E560F690083520F /* RelayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayController.swift; sourceTree = "<group>"; };
0481A3432E6D869F00FC845E /* TorManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorManager.swift; sourceTree = "<group>"; }; 0481A3432E6D869F00FC845E /* TorManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorManager.swift; sourceTree = "<group>"; };
0481A3442E6D869F00FC845E /* TorURLSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorURLSession.swift; sourceTree = "<group>"; }; 0481A3442E6D869F00FC845E /* TorURLSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorURLSession.swift; sourceTree = "<group>"; };
0C0EFA102E6EAAAA00ABCDEF /* CTorHost.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = CTorHost.c; sourceTree = "<group>"; };
0C0EFA132E6EAABB00ABCDF1 /* TorNotifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorNotifications.swift; sourceTree = "<group>"; };
0481A3532E6D877600FC845E /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; }; 0481A3532E6D877600FC845E /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
0481A3572E6D929E00FC845E /* tor-nolzma.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = "tor-nolzma.xcframework"; sourceTree = "<group>"; }; 0481A3572E6D929E00FC845E /* tor-nolzma.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = "tor-nolzma.xcframework"; sourceTree = "<group>"; };
0481A35A2E6D9BEF00FC845E /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; }; 0481A35A2E6D9BEF00FC845E /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; };
0481A35C2E6DA18600FC845E /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; }; 0481A35C2E6DA18600FC845E /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; };
0481A38F2E734CAE00FC845E /* CommandProcessorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandProcessorTests.swift; sourceTree = "<group>"; };
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransportConfig.swift; sourceTree = "<group>"; }; 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransportConfig.swift; sourceTree = "<group>"; };
048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashBookmarksStore.swift; sourceTree = "<group>"; }; 048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashBookmarksStore.swift; sourceTree = "<group>"; };
048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashBookmarksStoreTests.swift; sourceTree = "<group>"; }; 048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashBookmarksStoreTests.swift; sourceTree = "<group>"; };
@@ -247,6 +254,8 @@
049BD3B12E51F319001A566B /* NostrTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrTransport.swift; sourceTree = "<group>"; }; 049BD3B12E51F319001A566B /* NostrTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrTransport.swift; sourceTree = "<group>"; };
05BA20BC0F123F1507C5C247 /* IdentityModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityModels.swift; sourceTree = "<group>"; }; 05BA20BC0F123F1507C5C247 /* IdentityModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityModels.swift; sourceTree = "<group>"; };
0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocolTests.swift; sourceTree = "<group>"; }; 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocolTests.swift; sourceTree = "<group>"; };
0C0EFA102E6EAAAA00ABCDEF /* CTorHost.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = CTorHost.c; sourceTree = "<group>"; };
0C0EFA132E6EAABB00ABCDF1 /* TorNotifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorNotifications.swift; sourceTree = "<group>"; };
11186E29A064E8D210880E1B /* BitchatPeer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatPeer.swift; sourceTree = "<group>"; }; 11186E29A064E8D210880E1B /* BitchatPeer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatPeer.swift; sourceTree = "<group>"; };
1234567890ABCDEFFEDCBA02 /* PeerDisplayNameResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerDisplayNameResolver.swift; sourceTree = "<group>"; }; 1234567890ABCDEFFEDCBA02 /* PeerDisplayNameResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerDisplayNameResolver.swift; sourceTree = "<group>"; };
136696FC4436A02D98CE6A77 /* KeychainManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainManager.swift; sourceTree = "<group>"; }; 136696FC4436A02D98CE6A77 /* KeychainManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainManager.swift; sourceTree = "<group>"; };
@@ -298,9 +307,12 @@
EA706D8E5097785414646A8E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; EA706D8E5097785414646A8E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
EE7EFB209C86BBD956B749EC /* SecureLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureLogger.swift; sourceTree = "<group>"; }; EE7EFB209C86BBD956B749EC /* SecureLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureLogger.swift; sourceTree = "<group>"; };
EF625BB3AD919322C01A46B2 /* BitchatApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatApp.swift; sourceTree = "<group>"; }; EF625BB3AD919322C01A46B2 /* BitchatApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatApp.swift; sourceTree = "<group>"; };
F0A1B2C3D4E5F60718293A4A /* OSLog+Categories.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OSLog+Categories.swift"; sourceTree = "<group>"; };
FC75901A0F0073B5BB8356E7 /* TestConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestConstants.swift; sourceTree = "<group>"; }; FC75901A0F0073B5BB8356E7 /* TestConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestConstants.swift; sourceTree = "<group>"; };
FDC18D910D6FF2E8B1B6C885 /* SecureIdentityStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureIdentityStateManager.swift; sourceTree = "<group>"; }; FDC18D910D6FF2E8B1B6C885 /* SecureIdentityStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureIdentityStateManager.swift; sourceTree = "<group>"; };
FE7CCF2BD78A3F3DAE6DA145 /* MockBLEService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBLEService.swift; sourceTree = "<group>"; }; FE7CCF2BD78A3F3DAE6DA145 /* MockBLEService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBLEService.swift; sourceTree = "<group>"; };
A1B2C3D4E5F60123456789AA /* MockKeychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockKeychain.swift; sourceTree = "<group>"; };
A1B2C3D4E5F60123456789AB /* MockIdentityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockIdentityManager.swift; sourceTree = "<group>"; };
FF7AF93D874001FBD94C8306 /* bitchat-macOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "bitchat-macOS.entitlements"; sourceTree = "<group>"; }; FF7AF93D874001FBD94C8306 /* bitchat-macOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "bitchat-macOS.entitlements"; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@@ -458,6 +470,8 @@
children = ( children = (
C27328EE574221395B2B8E87 /* MockBluetoothMeshService.swift */, C27328EE574221395B2B8E87 /* MockBluetoothMeshService.swift */,
FE7CCF2BD78A3F3DAE6DA145 /* MockBLEService.swift */, FE7CCF2BD78A3F3DAE6DA145 /* MockBLEService.swift */,
A1B2C3D4E5F60123456789AA /* MockKeychain.swift */,
A1B2C3D4E5F60123456789AB /* MockIdentityManager.swift */,
); );
path = Mocks; path = Mocks;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -471,6 +485,7 @@
32F149C43D1915831B60FE09 /* CompressionUtil.swift */, 32F149C43D1915831B60FE09 /* CompressionUtil.swift */,
90CB7A5CD1D1A521CD31F380 /* InputValidator.swift */, 90CB7A5CD1D1A521CD31F380 /* InputValidator.swift */,
EE7EFB209C86BBD956B749EC /* SecureLogger.swift */, EE7EFB209C86BBD956B749EC /* SecureLogger.swift */,
F0A1B2C3D4E5F60718293A4A /* OSLog+Categories.swift */,
); );
path = Utils; path = Utils;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -538,8 +553,9 @@
C3D98EB3E1B455E321F519F4 /* bitchatTests */ = { C3D98EB3E1B455E321F519F4 /* bitchatTests */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */,
D69A18D27F9A565FD6041E12 /* Info.plist */, D69A18D27F9A565FD6041E12 /* Info.plist */,
0481A38F2E734CAE00FC845E /* CommandProcessorTests.swift */,
048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */,
047502912E547ACC0083520F /* LocationChannelsTests.swift */, 047502912E547ACC0083520F /* LocationChannelsTests.swift */,
C272F137CE00FC5A96E0CC06 /* NostrProtocolTests.swift */, C272F137CE00FC5A96E0CC06 /* NostrProtocolTests.swift */,
980B109CBA72BC996455C62B /* BLEServiceTests.swift */, 980B109CBA72BC996455C62B /* BLEServiceTests.swift */,
@@ -802,6 +818,7 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
F0A1B2C3D4E5F60718293A4C /* OSLog+Categories.swift in Sources */,
0C0EFA122E6EAAAA00ABCDF0 /* CTorHost.c in Sources */, 0C0EFA122E6EAAAA00ABCDF0 /* CTorHost.c in Sources */,
0C0EFA172E6EAABB00ABCDF5 /* TorNotifications.swift in Sources */, 0C0EFA172E6EAABB00ABCDF5 /* TorNotifications.swift in Sources */,
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */, 048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */,
@@ -866,6 +883,7 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
F0A1B2C3D4E5F60718293A4B /* OSLog+Categories.swift in Sources */,
0C0EFA112E6EAAAA00ABCDEF /* CTorHost.c in Sources */, 0C0EFA112E6EAAAA00ABCDEF /* CTorHost.c in Sources */,
0C0EFA162E6EAABB00ABCDF4 /* TorNotifications.swift in Sources */, 0C0EFA162E6EAABB00ABCDF4 /* TorNotifications.swift in Sources */,
048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */, 048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */,
@@ -934,10 +952,13 @@
047502802E53A0FC0083520F /* FragmentationTests.swift in Sources */, 047502802E53A0FC0083520F /* FragmentationTests.swift in Sources */,
8F282E9CCA5AE1ECC001D2E4 /* IntegrationTests.swift in Sources */, 8F282E9CCA5AE1ECC001D2E4 /* IntegrationTests.swift in Sources */,
047502B12E55E8450083520F /* InputValidatorTests.swift in Sources */, 047502B12E55E8450083520F /* InputValidatorTests.swift in Sources */,
0481A3912E734CAE00FC845E /* CommandProcessorTests.swift in Sources */,
D727EA273CB214FC32612469 /* MockBluetoothMeshService.swift in Sources */, D727EA273CB214FC32612469 /* MockBluetoothMeshService.swift in Sources */,
047502932E547ACC0083520F /* LocationChannelsTests.swift in Sources */, 047502932E547ACC0083520F /* LocationChannelsTests.swift in Sources */,
048A4C2B2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */, 048A4C2B2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */,
6C803BF930E7E19BE6E99EAA /* MockBLEService.swift in Sources */, 6C803BF930E7E19BE6E99EAA /* MockBLEService.swift in Sources */,
A1B2C3D4E5F60123456789BB /* MockKeychain.swift in Sources */,
A1B2C3D4E5F60123456789BD /* MockIdentityManager.swift in Sources */,
765254F56997F01054699AC0 /* NoiseProtocolTests.swift in Sources */, 765254F56997F01054699AC0 /* NoiseProtocolTests.swift in Sources */,
968181D255CA7A804340B4DA /* NostrProtocolTests.swift in Sources */, 968181D255CA7A804340B4DA /* NostrProtocolTests.swift in Sources */,
ED83C7AC1E6BEF15389C0132 /* PrivateChatE2ETests.swift in Sources */, ED83C7AC1E6BEF15389C0132 /* PrivateChatE2ETests.swift in Sources */,
@@ -957,10 +978,14 @@
047502812E53A0FC0083520F /* FragmentationTests.swift in Sources */, 047502812E53A0FC0083520F /* FragmentationTests.swift in Sources */,
686441ABC2AF83EE98E6ECF2 /* IntegrationTests.swift in Sources */, 686441ABC2AF83EE98E6ECF2 /* IntegrationTests.swift in Sources */,
047502B02E55E8450083520F /* InputValidatorTests.swift in Sources */, 047502B02E55E8450083520F /* InputValidatorTests.swift in Sources */,
0481A3902E734CAE00FC845E /* CommandProcessorTests.swift in Sources */,
8851F08D88C5B1DE7B9F55C6 /* MockBluetoothMeshService.swift in Sources */, 8851F08D88C5B1DE7B9F55C6 /* MockBluetoothMeshService.swift in Sources */,
047502922E547ACC0083520F /* LocationChannelsTests.swift in Sources */, 047502922E547ACC0083520F /* LocationChannelsTests.swift in Sources */,
048A4C2C2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */, 048A4C2C2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */,
3849CA6D99B2D536636DF4A6 /* MockBLEService.swift in Sources */, 3849CA6D99B2D536636DF4A6 /* MockBLEService.swift in Sources */,
A1B2C3D4E5F60123456789BA /* MockKeychain.swift in Sources */,
A1B2C3D4E5F60123456789BC /* MockIdentityManager.swift in Sources */,
BC4DC75F4FB823FF40569676 /* NoiseProtocolTests.swift in Sources */, BC4DC75F4FB823FF40569676 /* NoiseProtocolTests.swift in Sources */,
EE8C3ECADAB3083A2687D50B /* NostrProtocolTests.swift in Sources */, EE8C3ECADAB3083A2687D50B /* NostrProtocolTests.swift in Sources */,
0AE840940F21AFC07C226636 /* PrivateChatE2ETests.swift in Sources */, 0AE840940F21AFC07C226636 /* PrivateChatE2ETests.swift in Sources */,
@@ -1,111 +1,9 @@
{ {
"images" : [ "images" : [
{
"filename" : "icon_20x20@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "20x20"
},
{
"filename" : "icon_20x20@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "20x20"
},
{
"filename" : "icon_29x29@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "29x29"
},
{
"filename" : "icon_29x29@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "29x29"
},
{
"filename" : "icon_40x40@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "40x40"
},
{
"filename" : "icon_40x40@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "40x40"
},
{
"filename" : "icon_60x60@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "60x60"
},
{
"filename" : "icon_60x60@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "60x60"
},
{
"filename" : "icon_20x20.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "20x20"
},
{
"filename" : "icon_20x20@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "20x20"
},
{
"filename" : "icon_29x29.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "29x29"
},
{
"filename" : "icon_29x29@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "29x29"
},
{
"filename" : "icon_40x40.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "40x40"
},
{
"filename" : "icon_40x40@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "40x40"
},
{
"filename" : "icon_76x76.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "76x76"
},
{
"filename" : "icon_76x76@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "76x76"
},
{
"filename" : "icon_83.5x83.5@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "83.5x83.5"
},
{ {
"filename" : "icon_1024x1024.png", "filename" : "icon_1024x1024.png",
"idiom" : "ios-marketing", "idiom" : "universal",
"scale" : "1x", "platform" : "ios",
"size" : "1024x1024" "size" : "1024x1024"
}, },
{ {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 378 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 497 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 570 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 401 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 564 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 668 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 497 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 641 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 765 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 765 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 628 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 930 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 976 B

+12 -4
View File
@@ -11,7 +11,7 @@ import UserNotifications
@main @main
struct BitchatApp: App { struct BitchatApp: App {
@StateObject private var chatViewModel = ChatViewModel() @StateObject private var chatViewModel: ChatViewModel
#if os(iOS) #if os(iOS)
@Environment(\.scenePhase) var scenePhase @Environment(\.scenePhase) var scenePhase
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@@ -23,6 +23,14 @@ struct BitchatApp: App {
#endif #endif
init() { init() {
let keychain = KeychainManager()
_chatViewModel = StateObject(
wrappedValue: ChatViewModel(
keychain: keychain,
identityManager: SecureIdentityStateManager(keychain)
)
)
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
// Warm up georelay directory and refresh if stale (once/day) // Warm up georelay directory and refresh if stale (once/day)
GeoRelayDirectory.shared.prefetchIfNeeded() GeoRelayDirectory.shared.prefetchIfNeeded()
@@ -163,7 +171,7 @@ struct BitchatApp: App {
} }
#if os(iOS) #if os(iOS)
class AppDelegate: NSObject, UIApplicationDelegate { final class AppDelegate: NSObject, UIApplicationDelegate {
weak var chatViewModel: ChatViewModel? weak var chatViewModel: ChatViewModel?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
@@ -175,7 +183,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
#if os(macOS) #if os(macOS)
import AppKit import AppKit
class MacAppDelegate: NSObject, NSApplicationDelegate { final class MacAppDelegate: NSObject, NSApplicationDelegate {
weak var chatViewModel: ChatViewModel? weak var chatViewModel: ChatViewModel?
func applicationWillTerminate(_ notification: Notification) { func applicationWillTerminate(_ notification: Notification) {
@@ -188,7 +196,7 @@ class MacAppDelegate: NSObject, NSApplicationDelegate {
} }
#endif #endif
class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate { final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
static let shared = NotificationDelegate() static let shared = NotificationDelegate()
weak var chatViewModel: ChatViewModel? weak var chatViewModel: ChatViewModel?
+60 -102
View File
@@ -93,13 +93,51 @@
import Foundation import Foundation
import CryptoKit import CryptoKit
protocol SecureIdentityStateManagerProtocol {
// MARK: Secure Loading/Saving
func forceSave()
// MARK: Social Identity Management
func getSocialIdentity(for fingerprint: String) -> SocialIdentity?
// MARK: Cryptographic Identities
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?)
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity]
func updateSocialIdentity(_ identity: SocialIdentity)
// MARK: Favorites Management
func getFavorites() -> Set<String>
func setFavorite(_ fingerprint: String, isFavorite: Bool)
func isFavorite(fingerprint: String) -> Bool
// MARK: Blocked Users Management
func isBlocked(fingerprint: String) -> Bool
func setBlocked(_ fingerprint: String, isBlocked: Bool)
// MARK: Geohash (Nostr) Blocking
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool
func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool)
func getBlockedNostrPubkeys() -> Set<String>
// MARK: Ephemeral Session Management
func registerEphemeralSession(peerID: String, handshakeState: HandshakeState)
func updateHandshakeState(peerID: String, state: HandshakeState)
// MARK: Cleanup
func clearAllIdentityData()
func removeEphemeralSession(peerID: String)
// MARK: Verification
func setVerified(fingerprint: String, verified: Bool)
func isVerified(fingerprint: String) -> Bool
func getVerifiedFingerprints() -> Set<String>
}
/// Singleton manager for secure identity state persistence and retrieval. /// Singleton manager for secure identity state persistence and retrieval.
/// Provides thread-safe access to identity mappings with encryption at rest. /// Provides thread-safe access to identity mappings with encryption at rest.
/// All identity data is stored encrypted in the device Keychain for security. /// All identity data is stored encrypted in the device Keychain for security.
class SecureIdentityStateManager { final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
static let shared = SecureIdentityStateManager() private let keychain: KeychainManagerProtocol
private let keychain = KeychainManager.shared
private let cacheKey = "bitchat.identityCache.v2" private let cacheKey = "bitchat.identityCache.v2"
private let encryptionKeyName = "identityCacheEncryptionKey" private let encryptionKeyName = "identityCacheEncryptionKey"
@@ -108,9 +146,6 @@ class SecureIdentityStateManager {
private var cryptographicIdentities: [String: CryptographicIdentity] = [:] private var cryptographicIdentities: [String: CryptographicIdentity] = [:]
private var cache: IdentityCache = IdentityCache() private var cache: IdentityCache = IdentityCache()
// Pending actions before handshake
private var pendingActions: [String: PendingActions] = [:]
// Thread safety // Thread safety
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent) private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
@@ -122,14 +157,16 @@ class SecureIdentityStateManager {
// Encryption key // Encryption key
private let encryptionKey: SymmetricKey private let encryptionKey: SymmetricKey
private init() { init(_ keychain: KeychainManagerProtocol) {
self.keychain = keychain
// Generate or retrieve encryption key from keychain // Generate or retrieve encryption key from keychain
let loadedKey: SymmetricKey let loadedKey: SymmetricKey
// Try to load from keychain // Try to load from keychain
if let keyData = keychain.getIdentityKey(forKey: encryptionKeyName) { if let keyData = keychain.getIdentityKey(forKey: encryptionKeyName) {
loadedKey = SymmetricKey(data: keyData) loadedKey = SymmetricKey(data: keyData)
SecureLogger.logKeyOperation("load", keyType: "identity cache encryption key", success: true) SecureLogger.logKeyOperation(.load, keyType: "identity cache encryption key", success: true)
} }
// Generate new key if needed // Generate new key if needed
else { else {
@@ -137,7 +174,7 @@ class SecureIdentityStateManager {
let keyData = loadedKey.withUnsafeBytes { Data($0) } let keyData = loadedKey.withUnsafeBytes { Data($0) }
// Save to keychain // Save to keychain
let saved = keychain.saveIdentityKey(keyData, forKey: encryptionKeyName) let saved = keychain.saveIdentityKey(keyData, forKey: encryptionKeyName)
SecureLogger.logKeyOperation("generate", keyType: "identity cache encryption key", success: saved) SecureLogger.logKeyOperation(.generate, keyType: "identity cache encryption key", success: saved)
} }
self.encryptionKey = loadedKey self.encryptionKey = loadedKey
@@ -146,9 +183,13 @@ class SecureIdentityStateManager {
loadIdentityCache() loadIdentityCache()
} }
deinit {
forceSave()
}
// MARK: - Secure Loading/Saving // MARK: - Secure Loading/Saving
func loadIdentityCache() { private func loadIdentityCache() {
guard let encryptedData = keychain.getIdentityKey(forKey: cacheKey) else { guard let encryptedData = keychain.getIdentityKey(forKey: cacheKey) else {
// No existing cache, start fresh // No existing cache, start fresh
return return
@@ -160,16 +201,11 @@ class SecureIdentityStateManager {
cache = try JSONDecoder().decode(IdentityCache.self, from: decryptedData) cache = try JSONDecoder().decode(IdentityCache.self, from: decryptedData)
} catch { } catch {
// Log error but continue with empty cache // Log error but continue with empty cache
SecureLogger.logError(error, context: "Failed to load identity cache", category: SecureLogger.security) SecureLogger.error(error, context: "Failed to load identity cache", category: .security)
} }
} }
deinit { private func saveIdentityCache() {
// Force save any pending changes
forceSave()
}
func saveIdentityCache() {
// Mark that we need to save // Mark that we need to save
pendingSave = true pendingSave = true
@@ -191,36 +227,18 @@ class SecureIdentityStateManager {
let sealedBox = try AES.GCM.seal(data, using: encryptionKey) let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
let saved = keychain.saveIdentityKey(sealedBox.combined!, forKey: cacheKey) let saved = keychain.saveIdentityKey(sealedBox.combined!, forKey: cacheKey)
if saved { if saved {
SecureLogger.log("Identity cache saved to keychain", category: SecureLogger.security, level: .debug) SecureLogger.debug("Identity cache saved to keychain", category: .security)
} }
} catch { } catch {
SecureLogger.logError(error, context: "Failed to save identity cache", category: SecureLogger.security) SecureLogger.error(error, context: "Failed to save identity cache", category: .security)
} }
} }
// Force immediate save (for app termination) // Force immediate save (for app termination)
func forceSave() { func forceSave() {
saveTimer?.invalidate() saveTimer?.invalidate()
if pendingSave {
performSave() performSave()
} }
}
// MARK: - Identity Resolution
func resolveIdentity(peerID: String, claimedNickname: String) -> IdentityHint {
queue.sync {
// Check if we have candidates based on nickname
if let fingerprints = cache.nicknameIndex[claimedNickname] {
if fingerprints.count == 1 {
return .likelyKnown(fingerprint: fingerprints.first!)
} else {
return .ambiguous(candidates: fingerprints)
}
}
return .unknown
}
}
// MARK: - Social Identity Management // MARK: - Social Identity Management
@@ -301,11 +319,6 @@ class SecureIdentityStateManager {
} }
} }
/// Retrieve cryptographic identity by fingerprint
func getCryptographicIdentity(for fingerprint: String) -> CryptographicIdentity? {
queue.sync { cryptographicIdentities[fingerprint] }
}
/// Find cryptographic identities whose fingerprint prefix matches a peerID (16-hex) short ID /// Find cryptographic identities whose fingerprint prefix matches a peerID (16-hex) short ID
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] { func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] {
queue.sync { queue.sync {
@@ -315,12 +328,6 @@ class SecureIdentityStateManager {
} }
} }
func getAllSocialIdentities() -> [SocialIdentity] {
queue.sync {
return Array(cache.socialIdentities.values)
}
}
func updateSocialIdentity(_ identity: SocialIdentity) { func updateSocialIdentity(_ identity: SocialIdentity) {
queue.async(flags: .barrier) { queue.async(flags: .barrier) {
self.cache.socialIdentities[identity.fingerprint] = identity self.cache.socialIdentities[identity.fingerprint] = identity
@@ -395,7 +402,7 @@ class SecureIdentityStateManager {
} }
func setBlocked(_ fingerprint: String, isBlocked: Bool) { func setBlocked(_ fingerprint: String, isBlocked: Bool) {
SecureLogger.log("User \(isBlocked ? "blocked" : "unblocked"): \(fingerprint)", category: SecureLogger.security, level: .info) SecureLogger.info("User \(isBlocked ? "blocked" : "unblocked"): \(fingerprint)", category: .security)
queue.async(flags: .barrier) { queue.async(flags: .barrier) {
if var identity = self.cache.socialIdentities[fingerprint] { if var identity = self.cache.socialIdentities[fingerprint] {
@@ -469,81 +476,32 @@ class SecureIdentityStateManager {
} }
} }
func getHandshakeState(peerID: String) -> HandshakeState? {
queue.sync {
return ephemeralSessions[peerID]?.handshakeState
}
}
// MARK: - Pending Actions
func setPendingAction(peerID: String, action: PendingActions) {
queue.async(flags: .barrier) {
self.pendingActions[peerID] = action
}
}
func applyPendingActions(peerID: String, fingerprint: String) {
queue.async(flags: .barrier) {
guard let actions = self.pendingActions[peerID] else { return }
// Get or create social identity
var identity = self.cache.socialIdentities[fingerprint] ?? SocialIdentity(
fingerprint: fingerprint,
localPetname: nil,
claimedNickname: "Unknown",
trustLevel: .unknown,
isFavorite: false,
isBlocked: false,
notes: nil
)
// Apply pending actions
if let toggleFavorite = actions.toggleFavorite {
identity.isFavorite = toggleFavorite
}
if let trustLevel = actions.setTrustLevel {
identity.trustLevel = trustLevel
}
if let petname = actions.setPetname {
identity.localPetname = petname
}
// Save updated identity
self.cache.socialIdentities[fingerprint] = identity
self.pendingActions.removeValue(forKey: peerID)
self.saveIdentityCache()
}
}
// MARK: - Cleanup // MARK: - Cleanup
func clearAllIdentityData() { func clearAllIdentityData() {
SecureLogger.log("Clearing all identity data", category: SecureLogger.security, level: .warning) SecureLogger.warning("Clearing all identity data", category: .security)
queue.async(flags: .barrier) { queue.async(flags: .barrier) {
self.cache = IdentityCache() self.cache = IdentityCache()
self.ephemeralSessions.removeAll() self.ephemeralSessions.removeAll()
self.cryptographicIdentities.removeAll() self.cryptographicIdentities.removeAll()
self.pendingActions.removeAll()
// Delete from keychain // Delete from keychain
let deleted = self.keychain.deleteIdentityKey(forKey: self.cacheKey) let deleted = self.keychain.deleteIdentityKey(forKey: self.cacheKey)
SecureLogger.logKeyOperation("delete", keyType: "identity cache", success: deleted) SecureLogger.logKeyOperation(.delete, keyType: "identity cache", success: deleted)
} }
} }
func removeEphemeralSession(peerID: String) { func removeEphemeralSession(peerID: String) {
queue.async(flags: .barrier) { queue.async(flags: .barrier) {
self.ephemeralSessions.removeValue(forKey: peerID) self.ephemeralSessions.removeValue(forKey: peerID)
self.pendingActions.removeValue(forKey: peerID)
} }
} }
// MARK: - Verification // MARK: - Verification
func setVerified(fingerprint: String, verified: Bool) { func setVerified(fingerprint: String, verified: Bool) {
SecureLogger.log("Fingerprint \(verified ? "verified" : "unverified"): \(fingerprint)", category: SecureLogger.security, level: .info) SecureLogger.info("Fingerprint \(verified ? "verified" : "unverified"): \(fingerprint)", category: .security)
queue.async(flags: .barrier) { queue.async(flags: .barrier) {
if verified { if verified {
+2 -2
View File
@@ -35,10 +35,10 @@
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string> <string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
<key>NSBluetoothPeripheralUsageDescription</key> <key>NSBluetoothPeripheralUsageDescription</key>
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string> <string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>bitchat uses the camera to scan QR codes to verify peers.</string> <string>bitchat uses the camera to scan QR codes to verify peers.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>bluetooth-central</string> <string>bluetooth-central</string>
+17 -29
View File
@@ -9,7 +9,7 @@
import Foundation import Foundation
/// Coordinates Noise handshakes to prevent race conditions and ensure reliable encryption establishment /// Coordinates Noise handshakes to prevent race conditions and ensure reliable encryption establishment
class NoiseHandshakeCoordinator { final class NoiseHandshakeCoordinator {
// MARK: - Handshake State // MARK: - Handshake State
@@ -68,8 +68,7 @@ class NoiseHandshakeCoordinator {
switch state { switch state {
case .initiating(_, let lastAttempt): case .initiating(_, let lastAttempt):
if Date().timeIntervalSince(lastAttempt) > handshakeTimeout { if Date().timeIntervalSince(lastAttempt) > handshakeTimeout {
SecureLogger.log("Forcing new handshake with \(remotePeerID) - previous stuck in initiating", SecureLogger.warning("Forcing new handshake with \(remotePeerID) - previous stuck in initiating", category: .handshake)
category: SecureLogger.handshake, level: .warning)
return true return true
} }
default: default:
@@ -77,8 +76,7 @@ class NoiseHandshakeCoordinator {
} }
} }
SecureLogger.log("Already in active handshake with \(remotePeerID), state: \(state)", SecureLogger.debug("Already in active handshake with \(remotePeerID), state: \(state)", category: .handshake)
category: SecureLogger.handshake, level: .debug)
return false return false
} }
@@ -107,8 +105,7 @@ class NoiseHandshakeCoordinator {
handshakeQueue.async(flags: .barrier) { handshakeQueue.async(flags: .barrier) {
let attempt = self.getCurrentAttempt(for: peerID) + 1 let attempt = self.getCurrentAttempt(for: peerID) + 1
self.handshakeStates[peerID] = .initiating(attempt: attempt, lastAttempt: Date()) self.handshakeStates[peerID] = .initiating(attempt: attempt, lastAttempt: Date())
SecureLogger.log("Recording handshake initiation with \(peerID), attempt \(attempt)", SecureLogger.info("Recording handshake initiation with \(peerID), attempt \(attempt)", category: .handshake)
category: SecureLogger.handshake, level: .info)
} }
} }
@@ -116,8 +113,7 @@ class NoiseHandshakeCoordinator {
func recordHandshakeResponse(peerID: String) { func recordHandshakeResponse(peerID: String) {
handshakeQueue.async(flags: .barrier) { handshakeQueue.async(flags: .barrier) {
self.handshakeStates[peerID] = .responding(since: Date()) self.handshakeStates[peerID] = .responding(since: Date())
SecureLogger.log("Recording handshake response to \(peerID)", SecureLogger.info("Recording handshake response to \(peerID)", category: .handshake)
category: SecureLogger.handshake, level: .info)
} }
} }
@@ -125,8 +121,7 @@ class NoiseHandshakeCoordinator {
func recordHandshakeSuccess(peerID: String) { func recordHandshakeSuccess(peerID: String) {
handshakeQueue.async(flags: .barrier) { handshakeQueue.async(flags: .barrier) {
self.handshakeStates[peerID] = .established(since: Date()) self.handshakeStates[peerID] = .established(since: Date())
SecureLogger.log("Handshake successfully established with \(peerID)", SecureLogger.info("Handshake successfully established with \(peerID)", category: .handshake)
category: SecureLogger.handshake, level: .info)
} }
} }
@@ -136,8 +131,7 @@ class NoiseHandshakeCoordinator {
let attempts = self.getCurrentAttempt(for: peerID) let attempts = self.getCurrentAttempt(for: peerID)
let canRetry = attempts < self.maxHandshakeAttempts let canRetry = attempts < self.maxHandshakeAttempts
self.handshakeStates[peerID] = .failed(reason: reason, canRetry: canRetry, lastAttempt: Date()) self.handshakeStates[peerID] = .failed(reason: reason, canRetry: canRetry, lastAttempt: Date())
SecureLogger.log("Handshake failed with \(peerID): \(reason), canRetry: \(canRetry)", SecureLogger.warning("Handshake failed with \(peerID): \(reason), canRetry: \(canRetry)", category: .handshake)
category: SecureLogger.handshake, level: .warning)
} }
} }
@@ -146,8 +140,7 @@ class NoiseHandshakeCoordinator {
return handshakeQueue.sync { return handshakeQueue.sync {
// If we're already established, reject new handshakes // If we're already established, reject new handshakes
if case .established = handshakeStates[remotePeerID] { if case .established = handshakeStates[remotePeerID] {
SecureLogger.log("Rejecting handshake from \(remotePeerID) - already established", SecureLogger.debug("Rejecting handshake from \(remotePeerID) - already established", category: .handshake)
category: SecureLogger.handshake, level: .debug)
return false return false
} }
@@ -157,8 +150,7 @@ class NoiseHandshakeCoordinator {
if role == .initiator { if role == .initiator {
if case .initiating = handshakeStates[remotePeerID] { if case .initiating = handshakeStates[remotePeerID] {
// They shouldn't be initiating, but accept it to recover from race condition // They shouldn't be initiating, but accept it to recover from race condition
SecureLogger.log("Accepting handshake from \(remotePeerID) despite being initiator (race condition recovery)", SecureLogger.warning("Accepting handshake from \(remotePeerID) despite being initiator (race condition recovery)", category: .handshake)
category: SecureLogger.handshake, level: .warning)
return true return true
} }
} }
@@ -215,8 +207,7 @@ class NoiseHandshakeCoordinator {
func resetHandshakeState(for peerID: String) { func resetHandshakeState(for peerID: String) {
handshakeQueue.async(flags: .barrier) { handshakeQueue.async(flags: .barrier) {
self.handshakeStates.removeValue(forKey: peerID) self.handshakeStates.removeValue(forKey: peerID)
SecureLogger.log("Reset handshake state for \(peerID)", SecureLogger.debug("Reset handshake state for \(peerID)", category: .handshake)
category: SecureLogger.handshake, level: .debug)
} }
} }
@@ -256,8 +247,7 @@ class NoiseHandshakeCoordinator {
if isStale { if isStale {
stalePeerIDs.append(peerID) stalePeerIDs.append(peerID)
SecureLogger.log("Found stale handshake state for \(peerID): \(state)", SecureLogger.warning("Found stale handshake state for \(peerID): \(state)", category: .handshake)
category: SecureLogger.handshake, level: .warning)
} }
} }
@@ -270,8 +260,7 @@ class NoiseHandshakeCoordinator {
for i in 0..<sessionsToRemove { for i in 0..<sessionsToRemove {
let peerID = sortedSessions[i].peerID let peerID = sortedSessions[i].peerID
stalePeerIDs.append(peerID) stalePeerIDs.append(peerID)
SecureLogger.log("Removing old established session for \(peerID) to maintain session limit", SecureLogger.info("Removing old established session for \(peerID) to maintain session limit", category: .handshake)
category: SecureLogger.handshake, level: .info)
} }
} }
@@ -281,8 +270,7 @@ class NoiseHandshakeCoordinator {
} }
if !stalePeerIDs.isEmpty { if !stalePeerIDs.isEmpty {
SecureLogger.log("Cleaned up \(stalePeerIDs.count) stale handshake states", SecureLogger.info("Cleaned up \(stalePeerIDs.count) stale handshake states", category: .handshake)
category: SecureLogger.handshake, level: .info)
} }
return stalePeerIDs return stalePeerIDs
@@ -333,7 +321,7 @@ class NoiseHandshakeCoordinator {
/// Log current handshake states for debugging /// Log current handshake states for debugging
func logHandshakeStates() { func logHandshakeStates() {
handshakeQueue.sync { handshakeQueue.sync {
SecureLogger.log("=== Handshake States ===", category: SecureLogger.handshake, level: .debug) SecureLogger.debug("=== Handshake States ===", category: .handshake)
for (peerID, state) in handshakeStates { for (peerID, state) in handshakeStates {
let stateDesc: String let stateDesc: String
switch state { switch state {
@@ -352,16 +340,16 @@ class NoiseHandshakeCoordinator {
case .failed(let reason, let canRetry, let lastAttempt): case .failed(let reason, let canRetry, let lastAttempt):
stateDesc = "failed: \(reason) (canRetry: \(canRetry), last: \(lastAttempt))" stateDesc = "failed: \(reason) (canRetry: \(canRetry), last: \(lastAttempt))"
} }
SecureLogger.log(" \(peerID): \(stateDesc)", category: SecureLogger.handshake, level: .debug) SecureLogger.debug(" \(peerID): \(stateDesc)", category: .handshake)
} }
SecureLogger.log("========================", category: SecureLogger.handshake, level: .debug) SecureLogger.debug("========================", category: .handshake)
} }
} }
/// Clear all handshake states - used during panic mode /// Clear all handshake states - used during panic mode
func clearAllHandshakeStates() { func clearAllHandshakeStates() {
handshakeQueue.async(flags: .barrier) { handshakeQueue.async(flags: .barrier) {
SecureLogger.log("Clearing all handshake states for panic mode", category: SecureLogger.handshake, level: .warning) SecureLogger.warning("Clearing all handshake states for panic mode", category: .handshake)
self.handshakeStates.removeAll() self.handshakeStates.removeAll()
self.processedHandshakeMessages.removeAll() self.processedHandshakeMessages.removeAll()
} }
+28 -21
View File
@@ -79,7 +79,6 @@
import Foundation import Foundation
import CryptoKit import CryptoKit
import os.log
// Core Noise Protocol implementation // Core Noise Protocol implementation
// Based on the Noise Protocol Framework specification // Based on the Noise Protocol Framework specification
@@ -127,7 +126,7 @@ struct NoiseProtocolName {
/// Handles ChaCha20-Poly1305 AEAD encryption with automatic nonce management /// Handles ChaCha20-Poly1305 AEAD encryption with automatic nonce management
/// and replay protection using a sliding window algorithm. /// and replay protection using a sliding window algorithm.
/// - Warning: Nonce reuse would be catastrophic for security /// - Warning: Nonce reuse would be catastrophic for security
class NoiseCipherState { final class NoiseCipherState {
// Constants for replay protection // Constants for replay protection
private static let NONCE_SIZE_BYTES = 4 private static let NONCE_SIZE_BYTES = 4
private static let REPLAY_WINDOW_SIZE = 1024 private static let REPLAY_WINDOW_SIZE = 1024
@@ -285,7 +284,7 @@ class NoiseCipherState {
// Log high nonce values that might indicate issues // Log high nonce values that might indicate issues
if currentNonce > Self.HIGH_NONCE_WARNING_THRESHOLD { if currentNonce > Self.HIGH_NONCE_WARNING_THRESHOLD {
SecureLogger.log("High nonce value detected: \(currentNonce) - consider rekeying", category: SecureLogger.encryption, level: .warning) SecureLogger.warning("High nonce value detected: \(currentNonce) - consider rekeying", category: .encryption)
} }
return combinedPayload return combinedPayload
@@ -307,13 +306,13 @@ class NoiseCipherState {
if useExtractedNonce { if useExtractedNonce {
// Extract nonce and ciphertext from combined payload // Extract nonce and ciphertext from combined payload
guard let (extractedNonce, actualCiphertext) = try extractNonceFromCiphertextPayload(ciphertext) else { guard let (extractedNonce, actualCiphertext) = try extractNonceFromCiphertextPayload(ciphertext) else {
SecureLogger.log("Decrypt failed: Could not extract nonce from payload") SecureLogger.debug("Decrypt failed: Could not extract nonce from payload")
throw NoiseError.invalidCiphertext throw NoiseError.invalidCiphertext
} }
// Validate nonce with sliding window replay protection // Validate nonce with sliding window replay protection
guard isValidNonce(extractedNonce) else { guard isValidNonce(extractedNonce) else {
SecureLogger.log("Replay attack detected: nonce \(extractedNonce) rejected") SecureLogger.debug("Replay attack detected: nonce \(extractedNonce) rejected")
throw NoiseError.replayDetected throw NoiseError.replayDetected
} }
@@ -342,7 +341,7 @@ class NoiseCipherState {
// Log high nonce values that might indicate issues // Log high nonce values that might indicate issues
if decryptionNonce > Self.HIGH_NONCE_WARNING_THRESHOLD { if decryptionNonce > Self.HIGH_NONCE_WARNING_THRESHOLD {
SecureLogger.log("High nonce value detected: \(decryptionNonce) - consider rekeying", category: SecureLogger.encryption, level: .warning) SecureLogger.warning("High nonce value detected: \(decryptionNonce) - consider rekeying", category: .encryption)
} }
do { do {
@@ -355,9 +354,9 @@ class NoiseCipherState {
nonce += 1 nonce += 1
return plaintext return plaintext
} catch { } catch {
SecureLogger.log("Decrypt failed: \(error) for nonce \(decryptionNonce)") SecureLogger.debug("Decrypt failed: \(error) for nonce \(decryptionNonce)")
// Log authentication failures with nonce info // Log authentication failures with nonce info
SecureLogger.log("Decryption failed at nonce \(decryptionNonce)", category: SecureLogger.encryption, level: .error) SecureLogger.error("Decryption failed at nonce \(decryptionNonce)", category: .encryption)
throw error throw error
} }
} }
@@ -384,7 +383,7 @@ class NoiseCipherState {
/// Responsible for key derivation, protocol name hashing, and maintaining /// Responsible for key derivation, protocol name hashing, and maintaining
/// the chaining key that provides key separation between handshake messages. /// the chaining key that provides key separation between handshake messages.
/// - Note: This class implements the SymmetricState object from the Noise spec /// - Note: This class implements the SymmetricState object from the Noise spec
class NoiseSymmetricState { final class NoiseSymmetricState {
private var cipherState: NoiseCipherState private var cipherState: NoiseCipherState
private var chainingKey: Data private var chainingKey: Data
private var hash: Data private var hash: Data
@@ -488,9 +487,10 @@ class NoiseSymmetricState {
/// This is the main interface for establishing encrypted sessions between peers. /// This is the main interface for establishing encrypted sessions between peers.
/// Manages the handshake state machine, message patterns, and key derivation. /// Manages the handshake state machine, message patterns, and key derivation.
/// - Important: Each handshake instance should only be used once /// - Important: Each handshake instance should only be used once
class NoiseHandshakeState { final class NoiseHandshakeState {
private let role: NoiseRole private let role: NoiseRole
private let pattern: NoisePattern private let pattern: NoisePattern
private let keychain: KeychainManagerProtocol
private var symmetricState: NoiseSymmetricState private var symmetricState: NoiseSymmetricState
// Keys // Keys
@@ -506,9 +506,16 @@ class NoiseHandshakeState {
private var messagePatterns: [[NoiseMessagePattern]] = [] private var messagePatterns: [[NoiseMessagePattern]] = []
private var currentPattern = 0 private var currentPattern = 0
init(role: NoiseRole, pattern: NoisePattern, localStaticKey: Curve25519.KeyAgreement.PrivateKey? = nil, remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil) { init(
role: NoiseRole,
pattern: NoisePattern,
keychain: KeychainManagerProtocol,
localStaticKey: Curve25519.KeyAgreement.PrivateKey? = nil,
remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil
) {
self.role = role self.role = role
self.pattern = pattern self.pattern = pattern
self.keychain = keychain
// Initialize static keys // Initialize static keys
if let localKey = localStaticKey { if let localKey = localStaticKey {
@@ -579,7 +586,7 @@ class NoiseHandshakeState {
var sharedData = shared.withUnsafeBytes { Data($0) } var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData) symmetricState.mixKey(sharedData)
// Clear sensitive shared secret // Clear sensitive shared secret
KeychainManager.secureClear(&sharedData) keychain.secureClear(&sharedData)
case .es: case .es:
// DH(ephemeral, static) - direction depends on role // DH(ephemeral, static) - direction depends on role
@@ -627,7 +634,7 @@ class NoiseHandshakeState {
var sharedData = shared.withUnsafeBytes { Data($0) } var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData) symmetricState.mixKey(sharedData)
// Clear sensitive shared secret // Clear sensitive shared secret
KeychainManager.secureClear(&sharedData) keychain.secureClear(&sharedData)
} }
} }
@@ -661,7 +668,7 @@ class NoiseHandshakeState {
do { do {
remoteEphemeralPublic = try NoiseHandshakeState.validatePublicKey(ephemeralData) remoteEphemeralPublic = try NoiseHandshakeState.validatePublicKey(ephemeralData)
} catch { } catch {
SecureLogger.log("Invalid ephemeral public key received", category: SecureLogger.security, level: .warning) SecureLogger.warning("Invalid ephemeral public key received", category: .security)
throw NoiseError.invalidMessage throw NoiseError.invalidMessage
} }
symmetricState.mixHash(ephemeralData) symmetricState.mixHash(ephemeralData)
@@ -678,7 +685,7 @@ class NoiseHandshakeState {
let decrypted = try symmetricState.decryptAndHash(staticData) let decrypted = try symmetricState.decryptAndHash(staticData)
remoteStaticPublic = try NoiseHandshakeState.validatePublicKey(decrypted) remoteStaticPublic = try NoiseHandshakeState.validatePublicKey(decrypted)
} catch { } catch {
SecureLogger.logSecurityEvent(.authenticationFailed(peerID: "Unknown - handshake"), level: .error) SecureLogger.error(.authenticationFailed(peerID: "Unknown - handshake"))
throw NoiseError.authenticationFailure throw NoiseError.authenticationFailure
} }
@@ -715,7 +722,7 @@ class NoiseHandshakeState {
var sharedData = shared.withUnsafeBytes { Data($0) } var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData) symmetricState.mixKey(sharedData)
// Clear sensitive shared secret // Clear sensitive shared secret
KeychainManager.secureClear(&sharedData) keychain.secureClear(&sharedData)
} else { } else {
guard let localStatic = localStaticPrivate, guard let localStatic = localStaticPrivate,
let remoteEphemeral = remoteEphemeralPublic else { let remoteEphemeral = remoteEphemeralPublic else {
@@ -725,7 +732,7 @@ class NoiseHandshakeState {
var sharedData = shared.withUnsafeBytes { Data($0) } var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData) symmetricState.mixKey(sharedData)
// Clear sensitive shared secret // Clear sensitive shared secret
KeychainManager.secureClear(&sharedData) keychain.secureClear(&sharedData)
} }
case .se: case .se:
@@ -738,7 +745,7 @@ class NoiseHandshakeState {
var sharedData = shared.withUnsafeBytes { Data($0) } var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData) symmetricState.mixKey(sharedData)
// Clear sensitive shared secret // Clear sensitive shared secret
KeychainManager.secureClear(&sharedData) keychain.secureClear(&sharedData)
} else { } else {
guard let localEphemeral = localEphemeralPrivate, guard let localEphemeral = localEphemeralPrivate,
let remoteStatic = remoteStaticPublic else { let remoteStatic = remoteStaticPublic else {
@@ -748,7 +755,7 @@ class NoiseHandshakeState {
var sharedData = shared.withUnsafeBytes { Data($0) } var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData) symmetricState.mixKey(sharedData)
// Clear sensitive shared secret // Clear sensitive shared secret
KeychainManager.secureClear(&sharedData) keychain.secureClear(&sharedData)
} }
case .ss: case .ss:
@@ -877,7 +884,7 @@ extension NoiseHandshakeState {
// Check against known bad points // Check against known bad points
if lowOrderPoints.contains(keyData) { if lowOrderPoints.contains(keyData) {
SecureLogger.log("Low-order point detected", category: SecureLogger.security, level: .warning) SecureLogger.warning("Low-order point detected", category: .security)
throw NoiseError.invalidPublicKey throw NoiseError.invalidPublicKey
} }
@@ -887,7 +894,7 @@ extension NoiseHandshakeState {
return publicKey return publicKey
} catch { } catch {
// If CryptoKit rejects it, it's invalid // If CryptoKit rejects it, it's invalid
SecureLogger.log("CryptoKit validation failed", category: SecureLogger.security, level: .warning) SecureLogger.warning("CryptoKit validation failed", category: .security)
throw NoiseError.invalidPublicKey throw NoiseError.invalidPublicKey
} }
} }
@@ -61,7 +61,7 @@ struct NoiseSecurityValidator {
// MARK: - Enhanced Noise Session with Security // MARK: - Enhanced Noise Session with Security
class SecureNoiseSession: NoiseSession { final class SecureNoiseSession: NoiseSession {
private(set) var messageCount: UInt64 = 0 private(set) var messageCount: UInt64 = 0
private let sessionStartTime = Date() private let sessionStartTime = Date()
private(set) var lastActivityTime = Date() private(set) var lastActivityTime = Date()
@@ -135,7 +135,7 @@ class SecureNoiseSession: NoiseSession {
// MARK: - Rate Limiter // MARK: - Rate Limiter
class NoiseRateLimiter { final class NoiseRateLimiter {
private var handshakeTimestamps: [String: [Date]] = [:] // peerID -> timestamps private var handshakeTimestamps: [String: [Date]] = [:] // peerID -> timestamps
private var messageTimestamps: [String: [Date]] = [:] // peerID -> timestamps private var messageTimestamps: [String: [Date]] = [:] // peerID -> timestamps
@@ -153,7 +153,7 @@ class NoiseRateLimiter {
// Check global rate limit first // Check global rate limit first
globalHandshakeTimestamps = globalHandshakeTimestamps.filter { $0 > oneMinuteAgo } globalHandshakeTimestamps = globalHandshakeTimestamps.filter { $0 > oneMinuteAgo }
if globalHandshakeTimestamps.count >= NoiseSecurityConstants.maxGlobalHandshakesPerMinute { if globalHandshakeTimestamps.count >= NoiseSecurityConstants.maxGlobalHandshakesPerMinute {
SecureLogger.log("Global handshake rate limit exceeded: \(globalHandshakeTimestamps.count)/\(NoiseSecurityConstants.maxGlobalHandshakesPerMinute) per minute", category: SecureLogger.security, level: .warning) SecureLogger.warning("Global handshake rate limit exceeded: \(globalHandshakeTimestamps.count)/\(NoiseSecurityConstants.maxGlobalHandshakesPerMinute) per minute", category: .security)
return false return false
} }
@@ -162,7 +162,7 @@ class NoiseRateLimiter {
timestamps = timestamps.filter { $0 > oneMinuteAgo } timestamps = timestamps.filter { $0 > oneMinuteAgo }
if timestamps.count >= NoiseSecurityConstants.maxHandshakesPerMinute { if timestamps.count >= NoiseSecurityConstants.maxHandshakesPerMinute {
SecureLogger.log("Per-peer handshake rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxHandshakesPerMinute) per minute", category: SecureLogger.security, level: .warning) SecureLogger.warning("Per-peer handshake rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxHandshakesPerMinute) per minute", category: .security)
return false return false
} }
@@ -182,7 +182,7 @@ class NoiseRateLimiter {
// Check global rate limit first // Check global rate limit first
globalMessageTimestamps = globalMessageTimestamps.filter { $0 > oneSecondAgo } globalMessageTimestamps = globalMessageTimestamps.filter { $0 > oneSecondAgo }
if globalMessageTimestamps.count >= NoiseSecurityConstants.maxGlobalMessagesPerSecond { if globalMessageTimestamps.count >= NoiseSecurityConstants.maxGlobalMessagesPerSecond {
SecureLogger.log("Global message rate limit exceeded: \(globalMessageTimestamps.count)/\(NoiseSecurityConstants.maxGlobalMessagesPerSecond) per second", category: SecureLogger.security, level: .warning) SecureLogger.warning("Global message rate limit exceeded: \(globalMessageTimestamps.count)/\(NoiseSecurityConstants.maxGlobalMessagesPerSecond) per second", category: .security)
return false return false
} }
@@ -191,7 +191,7 @@ class NoiseRateLimiter {
timestamps = timestamps.filter { $0 > oneSecondAgo } timestamps = timestamps.filter { $0 > oneSecondAgo }
if timestamps.count >= NoiseSecurityConstants.maxMessagesPerSecond { if timestamps.count >= NoiseSecurityConstants.maxMessagesPerSecond {
SecureLogger.log("Per-peer message rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxMessagesPerSecond) per second", category: SecureLogger.security, level: .warning) SecureLogger.warning("Per-peer message rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxMessagesPerSecond) per second", category: .security)
return false return false
} }
+33 -20
View File
@@ -8,7 +8,6 @@
import Foundation import Foundation
import CryptoKit import CryptoKit
import os.log
// MARK: - Noise Session State // MARK: - Noise Session State
@@ -37,6 +36,7 @@ enum NoiseSessionState: Equatable {
class NoiseSession { class NoiseSession {
let peerID: String let peerID: String
let role: NoiseRole let role: NoiseRole
private let keychain: KeychainManagerProtocol
private var state: NoiseSessionState = .uninitialized private var state: NoiseSessionState = .uninitialized
private var handshakeState: NoiseHandshakeState? private var handshakeState: NoiseHandshakeState?
private var sendCipher: NoiseCipherState? private var sendCipher: NoiseCipherState?
@@ -53,9 +53,16 @@ class NoiseSession {
// Thread safety // Thread safety
private let sessionQueue = DispatchQueue(label: "chat.bitchat.noise.session", attributes: .concurrent) private let sessionQueue = DispatchQueue(label: "chat.bitchat.noise.session", attributes: .concurrent)
init(peerID: String, role: NoiseRole, localStaticKey: Curve25519.KeyAgreement.PrivateKey, remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil) { init(
peerID: String,
role: NoiseRole,
keychain: KeychainManagerProtocol,
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil
) {
self.peerID = peerID self.peerID = peerID
self.role = role self.role = role
self.keychain = keychain
self.localStaticKey = localStaticKey self.localStaticKey = localStaticKey
self.remoteStaticPublicKey = remoteStaticKey self.remoteStaticPublicKey = remoteStaticKey
} }
@@ -72,6 +79,7 @@ class NoiseSession {
handshakeState = NoiseHandshakeState( handshakeState = NoiseHandshakeState(
role: role, role: role,
pattern: .XX, pattern: .XX,
keychain: keychain,
localStaticKey: localStaticKey, localStaticKey: localStaticKey,
remoteStaticKey: nil remoteStaticKey: nil
) )
@@ -92,18 +100,19 @@ class NoiseSession {
func processHandshakeMessage(_ message: Data) throws -> Data? { func processHandshakeMessage(_ message: Data) throws -> Data? {
return try sessionQueue.sync(flags: .barrier) { return try sessionQueue.sync(flags: .barrier) {
SecureLogger.log("NoiseSession[\(peerID)]: Processing handshake message, current state: \(state), role: \(role)", category: SecureLogger.noise, level: .debug) SecureLogger.debug("NoiseSession[\(peerID)]: Processing handshake message, current state: \(state), role: \(role)")
// Initialize handshake state if needed (for responders) // Initialize handshake state if needed (for responders)
if state == .uninitialized && role == .responder { if state == .uninitialized && role == .responder {
handshakeState = NoiseHandshakeState( handshakeState = NoiseHandshakeState(
role: role, role: role,
pattern: .XX, pattern: .XX,
keychain: keychain,
localStaticKey: localStaticKey, localStaticKey: localStaticKey,
remoteStaticKey: nil remoteStaticKey: nil
) )
state = .handshaking state = .handshaking
SecureLogger.log("NoiseSession[\(peerID)]: Initialized handshake state for responder", category: SecureLogger.noise, level: .debug) SecureLogger.debug("NoiseSession[\(peerID)]: Initialized handshake state for responder")
} }
guard case .handshaking = state, let handshake = handshakeState else { guard case .handshaking = state, let handshake = handshakeState else {
@@ -112,7 +121,7 @@ class NoiseSession {
// Process incoming message // Process incoming message
_ = try handshake.readMessage(message) _ = try handshake.readMessage(message)
SecureLogger.log("NoiseSession[\(peerID)]: Read handshake message, checking if complete", category: SecureLogger.noise, level: .debug) SecureLogger.debug("NoiseSession[\(peerID)]: Read handshake message, checking if complete")
// Check if handshake is complete // Check if handshake is complete
if handshake.isHandshakeComplete() { if handshake.isHandshakeComplete() {
@@ -130,15 +139,15 @@ class NoiseSession {
state = .established state = .established
handshakeState = nil // Clear handshake state handshakeState = nil // Clear handshake state
SecureLogger.log("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established", category: SecureLogger.noise, level: .debug) SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established")
SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID)) SecureLogger.info(.handshakeCompleted(peerID: peerID))
return nil return nil
} else { } else {
// Generate response // Generate response
let response = try handshake.writeMessage() let response = try handshake.writeMessage()
sentHandshakeMessages.append(response) sentHandshakeMessages.append(response)
SecureLogger.log("NoiseSession[\(peerID)]: Generated handshake response of size \(response.count)", category: SecureLogger.noise, level: .debug) SecureLogger.debug("NoiseSession[\(peerID)]: Generated handshake response of size \(response.count)")
// Check if handshake is complete after writing // Check if handshake is complete after writing
if handshake.isHandshakeComplete() { if handshake.isHandshakeComplete() {
@@ -156,8 +165,8 @@ class NoiseSession {
state = .established state = .established
handshakeState = nil // Clear handshake state handshakeState = nil // Clear handshake state
SecureLogger.log("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established", category: SecureLogger.noise, level: .debug) SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established")
SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID)) SecureLogger.info(.handshakeCompleted(peerID: peerID))
} }
return response return response
@@ -231,18 +240,18 @@ class NoiseSession {
// Clear sent handshake messages // Clear sent handshake messages
for i in 0..<sentHandshakeMessages.count { for i in 0..<sentHandshakeMessages.count {
var message = sentHandshakeMessages[i] var message = sentHandshakeMessages[i]
KeychainManager.secureClear(&message) keychain.secureClear(&message)
} }
sentHandshakeMessages.removeAll() sentHandshakeMessages.removeAll()
// Clear handshake hash // Clear handshake hash
if var hash = handshakeHash { if var hash = handshakeHash {
KeychainManager.secureClear(&hash) keychain.secureClear(&hash)
} }
handshakeHash = nil handshakeHash = nil
if wasEstablished { if wasEstablished {
SecureLogger.logSecurityEvent(.sessionExpired(peerID: peerID)) SecureLogger.info(.sessionExpired(peerID: peerID))
} }
} }
} }
@@ -250,17 +259,19 @@ class NoiseSession {
// MARK: - Session Manager // MARK: - Session Manager
class NoiseSessionManager { final class NoiseSessionManager {
private var sessions: [String: NoiseSession] = [:] private var sessions: [String: NoiseSession] = [:]
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
private let keychain: KeychainManagerProtocol
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent) private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
// Callbacks // Callbacks
var onSessionEstablished: ((String, Curve25519.KeyAgreement.PublicKey) -> Void)? var onSessionEstablished: ((String, Curve25519.KeyAgreement.PublicKey) -> Void)?
var onSessionFailed: ((String, Error) -> Void)? var onSessionFailed: ((String, Error) -> Void)?
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey) { init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
self.localStaticKey = localStaticKey self.localStaticKey = localStaticKey
self.keychain = keychain
} }
// MARK: - Session Management // MARK: - Session Management
@@ -270,6 +281,7 @@ class NoiseSessionManager {
let session = SecureNoiseSession( let session = SecureNoiseSession(
peerID: peerID, peerID: peerID,
role: role, role: role,
keychain: keychain,
localStaticKey: localStaticKey localStaticKey: localStaticKey
) )
sessions[peerID] = session sessions[peerID] = session
@@ -287,7 +299,7 @@ class NoiseSessionManager {
managerQueue.sync(flags: .barrier) { managerQueue.sync(flags: .barrier) {
if let session = sessions[peerID] { if let session = sessions[peerID] {
if session.isEstablished() { if session.isEstablished() {
SecureLogger.logSecurityEvent(.sessionExpired(peerID: peerID)) SecureLogger.info(.sessionExpired(peerID: peerID))
} }
// Clear sensitive data before removing // Clear sensitive data before removing
session.reset() session.reset()
@@ -321,6 +333,7 @@ class NoiseSessionManager {
let session = SecureNoiseSession( let session = SecureNoiseSession(
peerID: peerID, peerID: peerID,
role: .initiator, role: .initiator,
keychain: keychain,
localStaticKey: localStaticKey localStaticKey: localStaticKey
) )
sessions[peerID] = session sessions[peerID] = session
@@ -331,7 +344,7 @@ class NoiseSessionManager {
} catch { } catch {
// Clean up failed session // Clean up failed session
_ = sessions.removeValue(forKey: peerID) _ = sessions.removeValue(forKey: peerID)
SecureLogger.logSecurityEvent(.handshakeFailed(peerID: peerID, error: error.localizedDescription), level: .error) SecureLogger.error(.handshakeFailed(peerID: peerID, error: error.localizedDescription))
throw error throw error
} }
} }
@@ -348,8 +361,7 @@ class NoiseSessionManager {
// for a good reason (e.g., decryption failure, restart, etc.) // for a good reason (e.g., decryption failure, restart, etc.)
// We should accept the new handshake to re-establish encryption // We should accept the new handshake to re-establish encryption
if existing.isEstablished() { if existing.isEstablished() {
SecureLogger.log("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session", SecureLogger.info("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session", category: .session)
category: SecureLogger.session, level: .info)
_ = sessions.removeValue(forKey: peerID) _ = sessions.removeValue(forKey: peerID)
shouldCreateNew = true shouldCreateNew = true
} else { } else {
@@ -372,6 +384,7 @@ class NoiseSessionManager {
let newSession = SecureNoiseSession( let newSession = SecureNoiseSession(
peerID: peerID, peerID: peerID,
role: .responder, role: .responder,
keychain: keychain,
localStaticKey: localStaticKey localStaticKey: localStaticKey
) )
sessions[peerID] = newSession sessions[peerID] = newSession
@@ -404,7 +417,7 @@ class NoiseSessionManager {
self?.onSessionFailed?(peerID, error) self?.onSessionFailed?(peerID, error)
} }
SecureLogger.logSecurityEvent(.handshakeFailed(peerID: peerID, error: error.localizedDescription), level: .error) SecureLogger.error(.handshakeFailed(peerID: peerID, error: error.localizedDescription))
throw error throw error
} }
} }
+5 -5
View File
@@ -54,7 +54,7 @@ final class GeoRelayDirectory {
Task.detached { Task.detached {
let ready = await TorManager.shared.awaitReady() let ready = await TorManager.shared.awaitReady()
if !ready { if !ready {
SecureLogger.log("GeoRelayDirectory: Tor not ready; skipping remote fetch (fail-closed)", category: SecureLogger.session, level: .warning) SecureLogger.warning("GeoRelayDirectory: Tor not ready; skipping remote fetch (fail-closed)", category: .session)
return return
} }
let task = TorURLSession.shared.session.dataTask(with: req) { [weak self] data, _, error in let task = TorURLSession.shared.session.dataTask(with: req) { [weak self] data, _, error in
@@ -66,12 +66,12 @@ final class GeoRelayDirectory {
self.entries = parsed self.entries = parsed
self.persistCache(text) self.persistCache(text)
UserDefaults.standard.set(Date(), forKey: self.lastFetchKey) UserDefaults.standard.set(Date(), forKey: self.lastFetchKey)
SecureLogger.log("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: SecureLogger.session, level: .info) SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
} }
return return
} }
} }
SecureLogger.log("GeoRelayDirectory: remote fetch failed; keeping local entries", category: SecureLogger.session, level: .warning) SecureLogger.warning("GeoRelayDirectory: remote fetch failed; keeping local entries", category: .session)
} }
task.resume() task.resume()
} }
@@ -82,7 +82,7 @@ final class GeoRelayDirectory {
do { do {
try text.data(using: .utf8)?.write(to: url, options: .atomic) try text.data(using: .utf8)?.write(to: url, options: .atomic)
} catch { } catch {
SecureLogger.log("GeoRelayDirectory: failed to write cache: \(error)", category: SecureLogger.session, level: .warning) SecureLogger.warning("GeoRelayDirectory: failed to write cache: \(error)", category: .session)
} }
} }
@@ -113,7 +113,7 @@ final class GeoRelayDirectory {
let text = String(data: data, encoding: .utf8) { let text = String(data: data, encoding: .utf8) {
return Self.parseCSV(text) return Self.parseCSV(text)
} }
SecureLogger.log("GeoRelayDirectory: no local CSV found; entries empty", category: SecureLogger.session, level: .warning) SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session)
return [] return []
} }
+3 -5
View File
@@ -78,8 +78,7 @@ struct NostrProtocol {
) )
// Successfully unwrapped gift wrap // Successfully unwrapped gift wrap
} catch { } catch {
SecureLogger.log("❌ Failed to unwrap gift wrap: \(error)", SecureLogger.error("❌ Failed to unwrap gift wrap: \(error)", category: .session)
category: SecureLogger.session, level: .error)
throw error throw error
} }
@@ -92,8 +91,7 @@ struct NostrProtocol {
) )
// Successfully opened seal // Successfully opened seal
} catch { } catch {
SecureLogger.log("❌ Failed to open seal: \(error)", SecureLogger.error("❌ Failed to open seal: \(error)", category: .session)
category: SecureLogger.session, level: .error)
throw error throw error
} }
@@ -109,7 +107,7 @@ struct NostrProtocol {
teleported: Bool = false teleported: Bool = false
) throws -> NostrEvent { ) throws -> NostrEvent {
var tags = [["g", geohash]] var tags = [["g", geohash]]
if let nickname = nickname, !nickname.isEmpty { if let nickname = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty {
tags.append(["n", nickname]) tags.append(["n", nickname])
} }
if teleported { if teleported {
+63 -67
View File
@@ -4,7 +4,7 @@ import Combine
/// Manages WebSocket connections to Nostr relays /// Manages WebSocket connections to Nostr relays
@MainActor @MainActor
class NostrRelayManager: ObservableObject { final class NostrRelayManager: ObservableObject {
static let shared = NostrRelayManager() static let shared = NostrRelayManager()
// Track gift-wraps (kind 1059) we initiated so we can log OK acks at info // Track gift-wraps (kind 1059) we initiated so we can log OK acks at info
private(set) static var pendingGiftWrapIDs = Set<String>() private(set) static var pendingGiftWrapIDs = Set<String>()
@@ -82,10 +82,10 @@ class NostrRelayManager: ObservableObject {
let ready = await TorManager.shared.awaitReady() let ready = await TorManager.shared.awaitReady()
await MainActor.run { await MainActor.run {
if !ready { if !ready {
SecureLogger.log("❌ Tor not ready; aborting relay connections (fail-closed)", category: SecureLogger.session, level: .error) SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
return return
} }
SecureLogger.log("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: SecureLogger.session, level: .debug) SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session)
for relay in self.relays { for relay in self.relays {
self.connectToRelay(relay.url) self.connectToRelay(relay.url)
} }
@@ -231,12 +231,11 @@ class NostrRelayManager: ObservableObject {
do { do {
let message = try encoder.encode(req) let message = try encoder.encode(req)
guard let messageString = String(data: message, encoding: .utf8) else { guard let messageString = String(data: message, encoding: .utf8) else {
SecureLogger.log("❌ Failed to encode subscription request", category: SecureLogger.session, level: .error) SecureLogger.error("❌ Failed to encode subscription request", category: .session)
return return
} }
// SecureLogger.log("📋 Subscription filter JSON: \(messageString.prefix(200))...", // SecureLogger.debug("📋 Subscription filter JSON: \(messageString.prefix(200))...", category: .session)
// category: SecureLogger.session, level: .debug)
// Target specific relays if provided; else default. Filter permanently failed relays. // Target specific relays if provided; else default. Filter permanently failed relays.
let baseUrls = relayUrls ?? Self.defaultRelays let baseUrls = relayUrls ?? Self.defaultRelays
@@ -251,8 +250,9 @@ class NostrRelayManager: ObservableObject {
map[id] = messageString map[id] = messageString
self.pendingSubscriptions[url] = map self.pendingSubscriptions[url] = map
} }
SecureLogger.log("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session)
category: SecureLogger.session, level: .debug) // Ensure we actually have sockets opening to these relays so queued REQs can flush
ensureConnections(to: urls)
// If some targets are already connected, flush immediately for them // If some targets are already connected, flush immediately for them
for url in urls { for url in urls {
if let r = relays.first(where: { $0.url == url }), r.isConnected { if let r = relays.first(where: { $0.url == url }), r.isConnected {
@@ -260,8 +260,7 @@ class NostrRelayManager: ObservableObject {
} }
} }
} catch { } catch {
SecureLogger.log("❌ Failed to encode subscription request: \(error)", SecureLogger.error("❌ Failed to encode subscription request: \(error)", category: .session)
category: SecureLogger.session, level: .error)
} }
} }
@@ -293,7 +292,7 @@ class NostrRelayManager: ObservableObject {
private func connectToRelay(_ urlString: String) { private func connectToRelay(_ urlString: String) {
guard let url = URL(string: urlString) else { guard let url = URL(string: urlString) else {
SecureLogger.log("Invalid relay URL: \(urlString)", category: SecureLogger.session, level: .warning) SecureLogger.warning("Invalid relay URL: \(urlString)", category: .session)
return return
} }
@@ -319,7 +318,7 @@ class NostrRelayManager: ObservableObject {
let ready = await TorManager.shared.awaitReady() let ready = await TorManager.shared.awaitReady()
await MainActor.run { await MainActor.run {
if ready { self.connectToRelay(urlString) } if ready { self.connectToRelay(urlString) }
else { SecureLogger.log("❌ Tor not ready; skipping connection to \(urlString)", category: SecureLogger.session, level: .error) } else { SecureLogger.error("❌ Tor not ready; skipping connection to \(urlString)", category: .session) }
} }
} }
return return
@@ -338,14 +337,12 @@ class NostrRelayManager: ObservableObject {
task.sendPing { [weak self] error in task.sendPing { [weak self] error in
DispatchQueue.main.async { DispatchQueue.main.async {
if error == nil { if error == nil {
SecureLogger.log("✅ Connected to Nostr relay: \(urlString)", SecureLogger.debug("✅ Connected to Nostr relay: \(urlString)", category: .session)
category: SecureLogger.session, level: .debug)
self?.updateRelayStatus(urlString, isConnected: true) self?.updateRelayStatus(urlString, isConnected: true)
// Flush any pending subscriptions for this relay // Flush any pending subscriptions for this relay
self?.flushPendingSubscriptions(for: urlString) self?.flushPendingSubscriptions(for: urlString)
} else { } else {
SecureLogger.log("❌ Failed to connect to Nostr relay \(urlString): \(error?.localizedDescription ?? "Unknown error")", SecureLogger.error("❌ Failed to connect to Nostr relay \(urlString): \(error?.localizedDescription ?? "Unknown error")", category: .session)
category: SecureLogger.session, level: .error)
self?.updateRelayStatus(urlString, isConnected: false, error: error) self?.updateRelayStatus(urlString, isConnected: false, error: error)
// Trigger disconnection handler for proper backoff // Trigger disconnection handler for proper backoff
self?.handleDisconnection(relayUrl: urlString, error: error ?? NSError(domain: "NostrRelay", code: -1, userInfo: nil)) self?.handleDisconnection(relayUrl: urlString, error: error ?? NSError(domain: "NostrRelay", code: -1, userInfo: nil))
@@ -362,8 +359,7 @@ class NostrRelayManager: ObservableObject {
if self.subscriptions[relayUrl]?.contains(id) == true { continue } if self.subscriptions[relayUrl]?.contains(id) == true { continue }
connection.send(.string(messageString)) { error in connection.send(.string(messageString)) { error in
if let error = error { if let error = error {
SecureLogger.log("❌ Failed to send pending subscription to \(relayUrl): \(error)", SecureLogger.error("❌ Failed to send pending subscription to \(relayUrl): \(error)", category: .session)
category: SecureLogger.session, level: .error)
} else { } else {
Task { @MainActor in Task { @MainActor in
var subs = self.subscriptions[relayUrl] ?? Set<String>() var subs = self.subscriptions[relayUrl] ?? Set<String>()
@@ -382,27 +378,13 @@ class NostrRelayManager: ObservableObject {
switch result { switch result {
case .success(let message): case .success(let message):
switch message {
case .string(let text):
// Parse off-main to reduce UI jank, then hop back for state updates // Parse off-main to reduce UI jank, then hop back for state updates
Task.detached(priority: .utility) { Task.detached(priority: .utility) {
guard let parsed = parseInboundMessage(text) else { return } guard let parsed = ParsedInbound(message) else { return }
await MainActor.run { await MainActor.run {
NostrRelayManager.shared.handleParsedMessage(parsed, from: relayUrl) NostrRelayManager.shared.handleParsedMessage(parsed, from: relayUrl)
} }
} }
case .data(let data):
if let text = String(data: data, encoding: .utf8) {
Task.detached(priority: .utility) {
guard let parsed = parseInboundMessage(text) else { return }
await MainActor.run {
NostrRelayManager.shared.handleParsedMessage(parsed, from: relayUrl)
}
}
}
@unknown default:
break
}
// Continue receiving // Continue receiving
Task { @MainActor in Task { @MainActor in
@@ -426,8 +408,7 @@ class NostrRelayManager: ObservableObject {
switch parsed { switch parsed {
case .event(let subId, let event): case .event(let subId, let event):
if event.kind != 1059 { if event.kind != 1059 {
SecureLogger.log("📥 Event kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", SecureLogger.debug("📥 Event kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
category: SecureLogger.session, level: .debug)
} }
if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) { if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) {
self.relays[index].messagesReceived += 1 self.relays[index].messagesReceived += 1
@@ -435,8 +416,7 @@ class NostrRelayManager: ObservableObject {
if let handler = self.messageHandlers[subId] { if let handler = self.messageHandlers[subId] {
handler(event) handler(event)
} else { } else {
SecureLogger.log("⚠️ No handler for subscription \(subId)", SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session)
category: SecureLogger.session, level: .warning)
} }
case .eose: case .eose:
// No-op for now // No-op for now
@@ -444,12 +424,14 @@ class NostrRelayManager: ObservableObject {
case .ok(let eventId, let success, let reason): case .ok(let eventId, let success, let reason):
if success { if success {
_ = Self.pendingGiftWrapIDs.remove(eventId) _ = Self.pendingGiftWrapIDs.remove(eventId)
SecureLogger.log("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)", SecureLogger.debug("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)", category: .session)
category: SecureLogger.session, level: .debug)
} else { } else {
let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil
SecureLogger.log("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)", if isGiftWrap {
category: SecureLogger.session, level: isGiftWrap ? .warning : .error) SecureLogger.warning("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)", category: .session)
} else {
SecureLogger.error("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)", category: .session)
}
} }
case .notice: case .notice:
break break
@@ -463,17 +445,14 @@ class NostrRelayManager: ObservableObject {
let data = try encoder.encode(req) let data = try encoder.encode(req)
let message = String(data: data, encoding: .utf8) ?? "" let message = String(data: data, encoding: .utf8) ?? ""
SecureLogger.log("📤 Send kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", SecureLogger.debug("📤 Send kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
category: SecureLogger.session, level: .debug)
connection.send(.string(message)) { [weak self] error in connection.send(.string(message)) { [weak self] error in
DispatchQueue.main.async { DispatchQueue.main.async {
if let error = error { if let error = error {
SecureLogger.log("❌ Failed to send event to \(relayUrl): \(error)", SecureLogger.error("❌ Failed to send event to \(relayUrl): \(error)", category: .session)
category: SecureLogger.session, level: .error)
} else { } else {
// SecureLogger.log(" Event sent to relay: \(relayUrl)", // SecureLogger.debug(" Event sent to relay: \(relayUrl)", category: .session)
// category: SecureLogger.session, level: .debug)
// Update relay stats // Update relay stats
if let index = self?.relays.firstIndex(where: { $0.url == relayUrl }) { if let index = self?.relays.firstIndex(where: { $0.url == relayUrl }) {
self?.relays[index].messagesSent += 1 self?.relays[index].messagesSent += 1
@@ -482,7 +461,7 @@ class NostrRelayManager: ObservableObject {
} }
} }
} catch { } catch {
SecureLogger.log("Failed to encode event: \(error)", category: SecureLogger.session, level: .error) SecureLogger.error("Failed to encode event: \(error)", category: .session)
} }
} }
@@ -521,7 +500,7 @@ class NostrRelayManager: ObservableObject {
errorDescription.contains("dns") || errorDescription.contains("dns") ||
(ns.domain == NSURLErrorDomain && ns.code == NSURLErrorBadServerResponse) { (ns.domain == NSURLErrorDomain && ns.code == NSURLErrorBadServerResponse) {
if relays.first(where: { $0.url == relayUrl })?.lastError == nil { if relays.first(where: { $0.url == relayUrl })?.lastError == nil {
SecureLogger.log("Nostr relay permanent failure for \(relayUrl) - not retrying (code=\(ns.code))", category: SecureLogger.session, level: .warning) SecureLogger.warning("Nostr relay permanent failure for \(relayUrl) - not retrying (code=\(ns.code))", category: .session)
} }
if let index = relays.firstIndex(where: { $0.url == relayUrl }) { if let index = relays.firstIndex(where: { $0.url == relayUrl }) {
relays[index].lastError = error relays[index].lastError = error
@@ -539,8 +518,7 @@ class NostrRelayManager: ObservableObject {
// Stop attempting after max attempts // Stop attempting after max attempts
if relays[index].reconnectAttempts >= maxReconnectAttempts { if relays[index].reconnectAttempts >= maxReconnectAttempts {
SecureLogger.log("Max reconnection attempts (\(maxReconnectAttempts)) reached for \(relayUrl)", SecureLogger.warning("Max reconnection attempts (\(maxReconnectAttempts)) reached for \(relayUrl)", category: .session)
category: SecureLogger.session, level: .warning)
return return
} }
@@ -634,42 +612,60 @@ private enum ParsedInbound {
case ok(eventId: String, success: Bool, reason: String) case ok(eventId: String, success: Bool, reason: String)
case eose(subscriptionId: String) case eose(subscriptionId: String)
case notice(String) case notice(String)
}
// Off-main JSON parse to avoid UI jank; pure function, not actor-isolated init?(_ message: URLSessionWebSocketTask.Message) {
private func parseInboundMessage(_ message: String) -> ParsedInbound? { guard let data = message.data,
guard let data = message.data(using: .utf8) else { return nil } let array = try? JSONSerialization.jsonObject(with: data) as? [Any],
do {
if let array = try JSONSerialization.jsonObject(with: data) as? [Any],
array.count >= 2, array.count >= 2,
let type = array[0] as? String { let type = array[0] as? String else {
return nil
}
switch type { switch type {
case "EVENT": case "EVENT":
if array.count >= 3, if array.count >= 3,
let subId = array[1] as? String, let subId = array[1] as? String,
let eventDict = array[2] as? [String: Any] { let eventDict = array[2] as? [String: Any],
let event = try NostrEvent(from: eventDict) let event = try? NostrEvent(from: eventDict) {
return .event(subId: subId, event: event) self = .event(subId: subId, event: event)
return
} }
return nil
case "EOSE": case "EOSE":
if let subId = array[1] as? String { return .eose(subscriptionId: subId) } if let subId = array[1] as? String {
self = .eose(subscriptionId: subId)
return
}
return nil
case "OK": case "OK":
if array.count >= 3, if array.count >= 3,
let eventId = array[1] as? String, let eventId = array[1] as? String,
let success = array[2] as? Bool { let success = array[2] as? Bool {
let reason = array.count >= 4 ? (array[3] as? String ?? "no reason given") : "no reason given" let reason = array.count >= 4 ? (array[3] as? String ?? "no reason given") : "no reason given"
return .ok(eventId: eventId, success: success, reason: reason) self = .ok(eventId: eventId, success: success, reason: reason)
return
} }
return nil
case "NOTICE": case "NOTICE":
if array.count >= 2, let msg = array[1] as? String { return .notice(msg) } if array.count >= 2, let msg = array[1] as? String {
self = .notice(msg)
return
}
return nil
default: default:
return nil return nil
} }
} }
} catch { }
// Ignore
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
}
} }
return nil
} }
// MARK: - Nostr Protocol Types // MARK: - Nostr Protocol Types
+1 -1
View File
@@ -397,7 +397,7 @@ enum DeliveryStatus: Codable, Equatable {
/// Handles both broadcast messages and private encrypted messages, /// Handles both broadcast messages and private encrypted messages,
/// with support for mentions, replies, and delivery tracking. /// with support for mentions, replies, and delivery tracking.
/// - Note: This is the primary data model for chat messages /// - Note: This is the primary data model for chat messages
class BitchatMessage: Codable { final class BitchatMessage: Codable {
let id: String let id: String
let sender: String let sender: String
let content: String let content: String
+1 -1
View File
@@ -9,7 +9,7 @@
import Foundation import Foundation
/// Manages autocomplete functionality for chat /// Manages autocomplete functionality for chat
class AutocompleteService { final class AutocompleteService {
private let mentionRegex = try? NSRegularExpression(pattern: "@([\\p{L}0-9_]*)$", options: []) private let mentionRegex = try? NSRegularExpression(pattern: "@([\\p{L}0-9_]*)$", options: [])
private let commandRegex = try? NSRegularExpression(pattern: "^/([a-z]*)$", options: []) private let commandRegex = try? NSRegularExpression(pattern: "^/([a-z]*)$", options: [])
+102 -126
View File
@@ -88,7 +88,8 @@ final class BLEService: NSObject {
var myPeerID: String = "" var myPeerID: String = ""
var myNickname: String = "anon" var myNickname: String = "anon"
private let noiseService = NoiseEncryptionService() private let noiseService: NoiseEncryptionService
private let identityManager: SecureIdentityStateManagerProtocol
private var myPeerIDData: Data = Data() private var myPeerIDData: Data = Data()
// MARK: - Advertising Privacy // MARK: - Advertising Privacy
@@ -230,8 +231,7 @@ final class BLEService: NSObject {
let newSize = data.count let newSize = data.count
// If single chunk exceeds cap, drop it immediately // If single chunk exceeds cap, drop it immediately
if newSize > capBytes { if newSize > capBytes {
SecureLogger.log("⚠️ Dropping oversized write chunk (\(newSize)B) for peripheral \(uuid)", SecureLogger.warning("⚠️ Dropping oversized write chunk (\(newSize)B) for peripheral \(uuid)", category: .session)
category: SecureLogger.session, level: .warning)
} else { } else {
// Append and trim from the front to respect cap // Append and trim from the front to respect cap
var total = queue.reduce(0) { $0 + $1.count } var total = queue.reduce(0) { $0 + $1.count }
@@ -244,8 +244,7 @@ final class BLEService: NSObject {
removedBytes += removed.count removedBytes += removed.count
total -= removed.count total -= removed.count
} }
SecureLogger.log("📉 Trimmed pending write buffer for \(uuid) by \(removedBytes)B to \(total)B", SecureLogger.warning("📉 Trimmed pending write buffer for \(uuid) by \(removedBytes)B to \(total)B", category: .session)
category: SecureLogger.session, level: .warning)
} }
self.pendingPeripheralWrites[uuid] = queue.isEmpty ? nil : queue self.pendingPeripheralWrites[uuid] = queue.isEmpty ? nil : queue
} }
@@ -327,7 +326,9 @@ final class BLEService: NSObject {
} }
} }
override init() { init(keychain: KeychainManagerProtocol, identityManager: SecureIdentityStateManagerProtocol) {
noiseService = NoiseEncryptionService(keychain: keychain)
self.identityManager = identityManager
super.init() super.init()
// Derive stable peer ID from Noise static public key fingerprint (first 8 bytes 16 hex chars) // Derive stable peer ID from Noise static public key fingerprint (first 8 bytes 16 hex chars)
@@ -341,8 +342,7 @@ final class BLEService: NSObject {
// Set up Noise session establishment callback // Set up Noise session establishment callback
// This ensures we send pending messages only when session is truly established // This ensures we send pending messages only when session is truly established
noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in
SecureLogger.log("🔐 Noise session authenticated with \(peerID), fingerprint: \(fingerprint.prefix(16))...", SecureLogger.debug("🔐 Noise session authenticated with \(peerID), fingerprint: \(fingerprint.prefix(16))...")
category: SecureLogger.noise, level: .debug)
// Send any messages that were queued during handshake // Send any messages that were queued during handshake
self?.messageQueue.async { [weak self] in self?.messageQueue.async { [weak self] in
self?.sendPendingMessagesAfterHandshake(for: peerID) self?.sendPendingMessagesAfterHandshake(for: peerID)
@@ -587,8 +587,7 @@ final class BLEService: NSObject {
} }
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) { func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
SecureLogger.log("🔔 sendFavoriteNotification called - peerID: \(peerID), isFavorite: \(isFavorite)", SecureLogger.debug("🔔 sendFavoriteNotification called - peerID: \(peerID), isFavorite: \(isFavorite)", category: .session)
category: SecureLogger.session, level: .debug)
// Include Nostr public key in the notification // Include Nostr public key in the notification
var content = isFavorite ? "[FAVORITED]" : "[UNFAVORITED]" var content = isFavorite ? "[FAVORITED]" : "[UNFAVORITED]"
@@ -596,12 +595,10 @@ final class BLEService: NSObject {
// Add our Nostr public key if available // Add our Nostr public key if available
if let myNostrIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() { if let myNostrIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() {
content += ":" + myNostrIdentity.npub content += ":" + myNostrIdentity.npub
SecureLogger.log("📝 Sending favorite notification with Nostr npub: \(myNostrIdentity.npub)", SecureLogger.debug("📝 Sending favorite notification with Nostr npub: \(myNostrIdentity.npub)", category: .session)
category: SecureLogger.session, level: .debug)
} }
SecureLogger.log("📤 Sending favorite notification to \(peerID): \(content)", SecureLogger.debug("📤 Sending favorite notification to \(peerID): \(content)", category: .session)
category: SecureLogger.session, level: .debug)
sendPrivateMessage(content, to: peerID, messageID: UUID().uuidString) sendPrivateMessage(content, to: peerID, messageID: UUID().uuidString)
} }
@@ -611,8 +608,7 @@ final class BLEService: NSObject {
payload.append(contentsOf: receipt.originalMessageID.utf8) payload.append(contentsOf: receipt.originalMessageID.utf8)
if noiseService.hasEstablishedSession(with: peerID) { if noiseService.hasEstablishedSession(with: peerID) {
SecureLogger.log("📤 Sending READ receipt for message \(receipt.originalMessageID) to \(peerID)", SecureLogger.debug("📤 Sending READ receipt for message \(receipt.originalMessageID) to \(peerID)", category: .session)
category: SecureLogger.session, level: .debug)
do { do {
let encrypted = try noiseService.encrypt(payload, for: peerID) let encrypted = try noiseService.encrypt(payload, for: peerID)
let packet = BitchatPacket( let packet = BitchatPacket(
@@ -630,7 +626,7 @@ final class BLEService: NSObject {
messageQueue.async { [weak self] in self?.broadcastPacket(packet) } messageQueue.async { [weak self] in self?.broadcastPacket(packet) }
} }
} catch { } catch {
SecureLogger.log("Failed to send read receipt: \(error)", category: SecureLogger.noise, level: .error) SecureLogger.error("Failed to send read receipt: \(error)")
} }
} else { } else {
// Queue for after handshake and initiate if needed // Queue for after handshake and initiate if needed
@@ -639,8 +635,7 @@ final class BLEService: NSObject {
self.pendingNoisePayloadsAfterHandshake[peerID, default: []].append(payload) self.pendingNoisePayloadsAfterHandshake[peerID, default: []].append(payload)
} }
if !noiseService.hasSession(with: peerID) { initiateNoiseHandshake(with: peerID) } if !noiseService.hasSession(with: peerID) { initiateNoiseHandshake(with: peerID) }
SecureLogger.log("🕒 Queued READ receipt for \(peerID) until handshake completes", SecureLogger.debug("🕒 Queued READ receipt for \(peerID) until handshake completes", category: .session)
category: SecureLogger.session, level: .debug)
} }
} }
@@ -682,7 +677,7 @@ final class BLEService: NSObject {
messageQueue.async { [weak self] in self?.broadcastPacket(packet) } messageQueue.async { [weak self] in self?.broadcastPacket(packet) }
} }
} catch { } catch {
SecureLogger.log("Failed to send verification payload: \(error)", category: SecureLogger.noise, level: .error) SecureLogger.error("Failed to send verification payload: \(error)")
} }
} }
@@ -748,7 +743,7 @@ final class BLEService: NSObject {
guard let self = self else { return } guard let self = self else { return }
guard content.count <= self.maxMessageLength else { guard content.count <= self.maxMessageLength else {
SecureLogger.log("Message too long: \(content.count) chars", category: SecureLogger.session, level: .error) SecureLogger.error("Message too long: \(content.count) chars", category: .session)
return return
} }
@@ -771,7 +766,7 @@ final class BLEService: NSObject {
ttl: self.messageTTL ttl: self.messageTTL
) )
guard let signedPacket = self.noiseService.signPacket(basePacket) else { guard let signedPacket = self.noiseService.signPacket(basePacket) else {
SecureLogger.log("❌ Failed to sign public message", category: SecureLogger.security, level: .error) SecureLogger.error("❌ Failed to sign public message", category: .security)
return return
} }
// Pre-mark our own broadcast as processed to avoid handling relayed self copy // Pre-mark our own broadcast as processed to avoid handling relayed self copy
@@ -789,7 +784,7 @@ final class BLEService: NSObject {
// MARK: - Private Message Handling // MARK: - Private Message Handling
private func sendPrivateMessage(_ content: String, to recipientID: String, messageID: String) { private func sendPrivateMessage(_ content: String, to recipientID: String, messageID: String) {
SecureLogger.log("📨 Sending PM to \(recipientID): \(content.prefix(30))...", category: SecureLogger.session, level: .debug) SecureLogger.debug("📨 Sending PM to \(recipientID): \(content.prefix(30))...", category: .session)
// Check if we have an established Noise session // Check if we have an established Noise session
if noiseService.hasEstablishedSession(with: recipientID) { if noiseService.hasEstablishedSession(with: recipientID) {
@@ -798,7 +793,7 @@ final class BLEService: NSObject {
// Create TLV-encoded private message // Create TLV-encoded private message
let privateMessage = PrivateMessagePacket(messageID: messageID, content: content) let privateMessage = PrivateMessagePacket(messageID: messageID, content: content)
guard let tlvData = privateMessage.encode() else { guard let tlvData = privateMessage.encode() else {
SecureLogger.log("Failed to encode private message with TLV", category: SecureLogger.noise, level: .error) SecureLogger.error("Failed to encode private message with TLV")
return return
} }
@@ -847,11 +842,11 @@ final class BLEService: NSObject {
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent) self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent)
} }
} catch { } catch {
SecureLogger.log("Failed to encrypt message: \(error)", category: SecureLogger.noise, level: .error) SecureLogger.error("Failed to encrypt message: \(error)")
} }
} else { } else {
// Queue message for sending after handshake completes // Queue message for sending after handshake completes
SecureLogger.log("🤝 No session with \(recipientID), initiating handshake and queueing message", category: SecureLogger.session, level: .debug) SecureLogger.debug("🤝 No session with \(recipientID), initiating handshake and queueing message", category: .session)
// Queue the message (especially important for favorite notifications) // Queue the message (especially important for favorite notifications)
collectionsQueue.sync(flags: .barrier) { collectionsQueue.sync(flags: .barrier) {
@@ -896,7 +891,7 @@ final class BLEService: NSObject {
} }
} }
} catch { } catch {
SecureLogger.log("Failed to initiate handshake: \(error)", category: SecureLogger.noise, level: .error) SecureLogger.error("Failed to initiate handshake: \(error)")
} }
} }
@@ -910,8 +905,7 @@ final class BLEService: NSObject {
guard let messages = pendingMessages, !messages.isEmpty else { return } guard let messages = pendingMessages, !messages.isEmpty else { return }
SecureLogger.log("📤 Sending \(messages.count) pending messages after handshake to \(peerID)", SecureLogger.debug("📤 Sending \(messages.count) pending messages after handshake to \(peerID)", category: .session)
category: SecureLogger.session, level: .debug)
// Send each pending message directly (we know session is established) // Send each pending message directly (we know session is established)
for (content, messageID) in messages { for (content, messageID) in messages {
@@ -919,7 +913,7 @@ final class BLEService: NSObject {
// Use the same TLV format as normal sends to keep receiver decoding consistent // Use the same TLV format as normal sends to keep receiver decoding consistent
let privateMessage = PrivateMessagePacket(messageID: messageID, content: content) let privateMessage = PrivateMessagePacket(messageID: messageID, content: content)
guard let tlvData = privateMessage.encode() else { guard let tlvData = privateMessage.encode() else {
SecureLogger.log("Failed to encode pending private message TLV", category: SecureLogger.noise, level: .error) SecureLogger.error("Failed to encode pending private message TLV")
continue continue
} }
@@ -946,11 +940,9 @@ final class BLEService: NSObject {
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent) self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent)
} }
SecureLogger.log("✅ Sent pending message \(messageID) to \(peerID) after handshake", SecureLogger.debug("✅ Sent pending message \(messageID) to \(peerID) after handshake", category: .session)
category: SecureLogger.session, level: .debug)
} catch { } catch {
SecureLogger.log("Failed to send pending message after handshake: \(error)", SecureLogger.error("Failed to send pending message after handshake: \(error)")
category: SecureLogger.noise, level: .error)
// Notify delegate of failure // Notify delegate of failure
notifyUI { [weak self] in notifyUI { [weak self] in
@@ -966,7 +958,7 @@ final class BLEService: NSObject {
// Encode once using a small per-type padding policy, then delegate by type // Encode once using a small per-type padding policy, then delegate by type
let padForBLE = padPolicy(for: packet.type) let padForBLE = padPolicy(for: packet.type)
guard let data = packet.toBinaryData(padding: padForBLE) else { guard let data = packet.toBinaryData(padding: padForBLE) else {
SecureLogger.log("❌ Failed to convert packet to binary data", category: SecureLogger.session, level: .error) SecureLogger.error("❌ Failed to convert packet to binary data", category: .session)
return return
} }
if packet.type == MessageType.noiseEncrypted.rawValue { if packet.type == MessageType.noiseEncrypted.rawValue {
@@ -1037,7 +1029,7 @@ final class BLEService: NSObject {
guard let self = self else { return } guard let self = self else { return }
if self.pendingNotifications.count < TransportConfig.blePendingNotificationsCapCount { if self.pendingNotifications.count < TransportConfig.blePendingNotificationsCapCount {
self.pendingNotifications.append((data: data, centrals: [central])) self.pendingNotifications.append((data: data, centrals: [central]))
SecureLogger.log("📋 Queued encrypted packet for retry (notification queue full)", category: SecureLogger.session, level: .debug) SecureLogger.debug("📋 Queued encrypted packet for retry (notification queue full)", category: .session)
} }
} }
} }
@@ -1150,7 +1142,7 @@ final class BLEService: NSObject {
if byMsg[msgID] == nil { if byMsg[msgID] == nil {
byMsg[msgID] = (packet: packet, enqueuedAt: Date()) byMsg[msgID] = (packet: packet, enqueuedAt: Date())
self.pendingDirectedRelays[recipientPeerID] = byMsg self.pendingDirectedRelays[recipientPeerID] = byMsg
SecureLogger.log("🧳 Spooling directed packet for \(recipientPeerID) mid=\(msgID.prefix(8))", category: SecureLogger.session, level: .debug) SecureLogger.debug("🧳 Spooling directed packet for \(recipientPeerID) mid=\(msgID.prefix(8))", category: .session)
} }
} }
} }
@@ -1322,7 +1314,7 @@ final class BLEService: NSObject {
if let originalPacket = BinaryProtocol.decode(reassembled) { if let originalPacket = BinaryProtocol.decode(reassembled) {
handleReceivedPacket(originalPacket, from: peerID) handleReceivedPacket(originalPacket, from: peerID)
} else { } else {
SecureLogger.log("❌ Failed to decode reassembled packet (type=\(originalType), total=\(total))", category: SecureLogger.session, level: .error) SecureLogger.error("❌ Failed to decode reassembled packet (type=\(originalType), total=\(total))", category: .session)
} }
// Cleanup // Cleanup
@@ -1342,8 +1334,7 @@ final class BLEService: NSObject {
// Only log non-announce packets to reduce noise // Only log non-announce packets to reduce noise
if packet.type != MessageType.announce.rawValue { if packet.type != MessageType.announce.rawValue {
// Log packet details for debugging // Log packet details for debugging
SecureLogger.log("📦 Handling packet type \(packet.type) from \(senderID), messageID: \(messageID)", SecureLogger.debug("📦 Handling packet type \(packet.type) from \(senderID), messageID: \(messageID)", category: .session)
category: SecureLogger.session, level: .debug)
} }
// Efficient deduplication // Efficient deduplication
@@ -1352,8 +1343,7 @@ final class BLEService: NSObject {
// Announce packets (type 1) are sent every 10 seconds for peer discovery // Announce packets (type 1) are sent every 10 seconds for peer discovery
// It's normal to see these as duplicates - don't log them to reduce noise // It's normal to see these as duplicates - don't log them to reduce noise
if packet.type != MessageType.announce.rawValue { if packet.type != MessageType.announce.rawValue {
SecureLogger.log("⚠️ Duplicate packet ignored: \(messageID)", SecureLogger.debug("⚠️ Duplicate packet ignored: \(messageID)", category: .session)
category: SecureLogger.session, level: .debug)
} }
// In sparse graphs (<=2 neighbors), keep the pending relay to ensure bridging. // In sparse graphs (<=2 neighbors), keep the pending relay to ensure bridging.
// In denser graphs, cancel the pending relay to reduce redundant floods. // In denser graphs, cancel the pending relay to reduce redundant floods.
@@ -1406,7 +1396,7 @@ final class BLEService: NSObject {
handleLeave(packet, from: senderID) handleLeave(packet, from: senderID)
default: default:
SecureLogger.log("⚠️ Unknown message type: \(packet.type)", category: SecureLogger.session, level: .warning) SecureLogger.warning("⚠️ Unknown message type: \(packet.type)", category: .session)
break break
} }
@@ -1446,7 +1436,7 @@ final class BLEService: NSObject {
private func handleAnnounce(_ packet: BitchatPacket, from peerID: String) { private func handleAnnounce(_ packet: BitchatPacket, from peerID: String) {
guard let announcement = AnnouncementPacket.decode(from: packet.payload) else { guard let announcement = AnnouncementPacket.decode(from: packet.payload) else {
SecureLogger.log("❌ Failed to decode announce packet from \(peerID)", category: SecureLogger.session, level: .error) SecureLogger.error("❌ Failed to decode announce packet from \(peerID)", category: .session)
return return
} }
@@ -1454,7 +1444,7 @@ final class BLEService: NSObject {
// This helps detect relayed or spoofed announces. Only warn in release; assert in debug. // This helps detect relayed or spoofed announces. Only warn in release; assert in debug.
let derivedFromKey = PeerIDUtils.derivePeerID(fromPublicKey: announcement.noisePublicKey) let derivedFromKey = PeerIDUtils.derivePeerID(fromPublicKey: announcement.noisePublicKey)
if derivedFromKey != peerID { if derivedFromKey != peerID {
SecureLogger.log("⚠️ Announce sender mismatch: derived \(derivedFromKey.prefix(8))… vs packet \(peerID.prefix(8))", category: SecureLogger.security, level: .warning) SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.prefix(8))… vs packet \(peerID.prefix(8))", category: .security)
} }
@@ -1471,11 +1461,11 @@ final class BLEService: NSObject {
if packet.signature != nil { if packet.signature != nil {
verifiedAnnounce = noiseService.verifyPacketSignature(packet, publicKey: announcement.signingPublicKey) verifiedAnnounce = noiseService.verifyPacketSignature(packet, publicKey: announcement.signingPublicKey)
if !verifiedAnnounce { if !verifiedAnnounce {
SecureLogger.log("⚠️ Signature verification for announce failed \(peerID.prefix(8))", category: SecureLogger.security, level: .warning) SecureLogger.warning("⚠️ Signature verification for announce failed \(peerID.prefix(8))", category: .security)
} }
} }
if let existingKey = existingPeerForVerify?.noisePublicKey, existingKey != announcement.noisePublicKey { if let existingKey = existingPeerForVerify?.noisePublicKey, existingKey != announcement.noisePublicKey {
SecureLogger.log("⚠️ Announce key mismatch for \(peerID.prefix(8))… — keeping unverified", category: SecureLogger.security, level: .warning) SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.prefix(8))… — keeping unverified", category: .security)
verifiedAnnounce = false verifiedAnnounce = false
} }
@@ -1508,7 +1498,7 @@ final class BLEService: NSObject {
// Require verified announce; ignore otherwise (no backward compatibility) // Require verified announce; ignore otherwise (no backward compatibility)
if !verified { if !verified {
SecureLogger.log("❌ Ignoring unverified announce from \(peerID.prefix(8))", category: SecureLogger.security, level: .warning) SecureLogger.warning("❌ Ignoring unverified announce from \(peerID.prefix(8))", category: .security)
return return
} }
@@ -1541,17 +1531,17 @@ final class BLEService: NSObject {
if isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription { if isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription {
let now = Date() let now = Date()
if existingPeer == nil { if existingPeer == nil {
SecureLogger.log("🆕 New peer: \(announcement.nickname)", category: SecureLogger.session, level: .debug) SecureLogger.debug("🆕 New peer: \(announcement.nickname)", category: .session)
} else if wasDisconnected { } else if wasDisconnected {
// Debounce 'reconnected' logs within short window // Debounce 'reconnected' logs within short window
if let last = lastReconnectLogAt[peerID], now.timeIntervalSince(last) < TransportConfig.bleReconnectLogDebounceSeconds { if let last = lastReconnectLogAt[peerID], now.timeIntervalSince(last) < TransportConfig.bleReconnectLogDebounceSeconds {
// Skip duplicate log // Skip duplicate log
} else { } else {
SecureLogger.log("🔄 Peer \(announcement.nickname) reconnected", category: SecureLogger.session, level: .debug) SecureLogger.debug("🔄 Peer \(announcement.nickname) reconnected", category: .session)
lastReconnectLogAt[peerID] = now lastReconnectLogAt[peerID] = now
} }
} else if existingPeer?.nickname != announcement.nickname { } else if existingPeer?.nickname != announcement.nickname {
SecureLogger.log("🔄 Peer \(peerID) changed nickname: \(existingPeer?.nickname ?? "Unknown") -> \(announcement.nickname)", category: SecureLogger.session, level: .debug) SecureLogger.debug("🔄 Peer \(peerID) changed nickname: \(existingPeer?.nickname ?? "Unknown") -> \(announcement.nickname)", category: .session)
} }
} }
} }
@@ -1561,7 +1551,7 @@ final class BLEService: NSObject {
// Derive fingerprint from Noise public key // Derive fingerprint from Noise public key
let hash = SHA256.hash(data: announcement.noisePublicKey) let hash = SHA256.hash(data: announcement.noisePublicKey)
let fingerprint = hash.map { String(format: "%02x", $0) }.joined() let fingerprint = hash.map { String(format: "%02x", $0) }.joined()
SecureIdentityStateManager.shared.upsertCryptographicIdentity( identityManager.upsertCryptographicIdentity(
fingerprint: fingerprint, fingerprint: fingerprint,
noisePublicKey: announcement.noisePublicKey, noisePublicKey: announcement.noisePublicKey,
signingPublicKey: announcement.signingPublicKey, signingPublicKey: announcement.signingPublicKey,
@@ -1643,13 +1633,13 @@ final class BLEService: NSObject {
// Fallback: verify signature using persisted signing key for this peerID's fingerprint prefix // Fallback: verify signature using persisted signing key for this peerID's fingerprint prefix
if let signature = packet.signature, let packetData = packet.toBinaryDataForSigning() { if let signature = packet.signature, let packetData = packet.toBinaryDataForSigning() {
// Find candidate identities by peerID prefix (16 hex) // Find candidate identities by peerID prefix (16 hex)
let candidates = SecureIdentityStateManager.shared.getCryptoIdentitiesByPeerIDPrefix(peerID) let candidates = identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID)
for candidate in candidates { for candidate in candidates {
if let signingKey = candidate.signingPublicKey, if let signingKey = candidate.signingPublicKey,
noiseService.verifySignature(signature, for: packetData, publicKey: signingKey) { noiseService.verifySignature(signature, for: packetData, publicKey: signingKey) {
accepted = true accepted = true
// Prefer persisted social petname or claimed nickname // Prefer persisted social petname or claimed nickname
if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: candidate.fingerprint) { if let social = identityManager.getSocialIdentity(for: candidate.fingerprint) {
senderNickname = social.localPetname ?? social.claimedNickname senderNickname = social.localPetname ?? social.claimedNickname
} else { } else {
senderNickname = "anon" + String(peerID.prefix(4)) senderNickname = "anon" + String(peerID.prefix(4))
@@ -1661,12 +1651,12 @@ final class BLEService: NSObject {
} }
guard accepted else { guard accepted else {
SecureLogger.log("🚫 Dropping public message from unverified or unknown peer \(peerID.prefix(8))", category: SecureLogger.security, level: .warning) SecureLogger.warning("🚫 Dropping public message from unverified or unknown peer \(peerID.prefix(8))", category: .security)
return return
} }
guard let content = String(data: packet.payload, encoding: .utf8) else { guard let content = String(data: packet.payload, encoding: .utf8) else {
SecureLogger.log("❌ Failed to decode message payload as UTF-8", category: SecureLogger.session, level: .error) SecureLogger.error("❌ Failed to decode message payload as UTF-8", category: .session)
return return
} }
// Determine if we have a direct link to the sender // Determine if we have a direct link to the sender
@@ -1678,7 +1668,7 @@ final class BLEService: NSObject {
} }
let pathTag = hasDirectLink ? "direct" : "mesh" let pathTag = hasDirectLink ? "direct" : "mesh"
SecureLogger.log("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)): \(String(content.prefix(50)))\(content.count > 50 ? "..." : "")", category: SecureLogger.session, level: .debug) SecureLogger.debug("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)): \(String(content.prefix(50)))\(content.count > 50 ? "..." : "")", category: .session)
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
notifyUI { [weak self] in notifyUI { [weak self] in
@@ -1710,7 +1700,7 @@ final class BLEService: NSObject {
// Session establishment will trigger onPeerAuthenticated callback // Session establishment will trigger onPeerAuthenticated callback
// which will send any pending messages at the right time // which will send any pending messages at the right time
} catch { } catch {
SecureLogger.log("Failed to process handshake: \(error)", category: SecureLogger.noise, level: .error) SecureLogger.error("Failed to process handshake: \(error)")
// Try initiating a new handshake // Try initiating a new handshake
if !noiseService.hasSession(with: peerID) { if !noiseService.hasSession(with: peerID) {
initiateNoiseHandshake(with: peerID) initiateNoiseHandshake(with: peerID)
@@ -1720,17 +1710,16 @@ final class BLEService: NSObject {
} }
private func handleNoiseEncrypted(_ packet: BitchatPacket, from peerID: String) { private func handleNoiseEncrypted(_ packet: BitchatPacket, from peerID: String) {
SecureLogger.log("🔐 handleNoiseEncrypted called for packet from \(peerID)", SecureLogger.debug("🔐 handleNoiseEncrypted called for packet from \(peerID)")
category: SecureLogger.noise, level: .debug)
guard let recipientID = packet.recipientID else { guard let recipientID = packet.recipientID else {
SecureLogger.log("⚠️ Encrypted message has no recipient ID", category: SecureLogger.session, level: .warning) SecureLogger.warning("⚠️ Encrypted message has no recipient ID", category: .session)
return return
} }
let recipientHex = recipientID.hexEncodedString() let recipientHex = recipientID.hexEncodedString()
if recipientHex != myPeerID { if recipientHex != myPeerID {
SecureLogger.log("🔐 Encrypted message not for me (for \(recipientHex), I am \(myPeerID))", category: SecureLogger.session, level: .debug) SecureLogger.debug("🔐 Encrypted message not for me (for \(recipientHex), I am \(myPeerID))", category: .session)
return return
} }
@@ -1772,19 +1761,17 @@ final class BLEService: NSObject {
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyResponse, payload: Data(payloadData), timestamp: ts) self?.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyResponse, payload: Data(payloadData), timestamp: ts)
} }
default: default:
SecureLogger.log("⚠️ Unknown noise payload type: \(payloadType)", category: SecureLogger.noise, level: .warning) SecureLogger.warning("⚠️ Unknown noise payload type: \(payloadType)")
} }
} catch NoiseEncryptionError.sessionNotEstablished { } catch NoiseEncryptionError.sessionNotEstablished {
// We received an encrypted message before establishing a session with this peer. // We received an encrypted message before establishing a session with this peer.
// Trigger a handshake so future messages can be decrypted. // Trigger a handshake so future messages can be decrypted.
SecureLogger.log("🔑 Encrypted message from \(peerID) without session; initiating handshake", SecureLogger.debug("🔑 Encrypted message from \(peerID) without session; initiating handshake")
category: SecureLogger.noise, level: .debug)
if !noiseService.hasSession(with: peerID) { if !noiseService.hasSession(with: peerID) {
initiateNoiseHandshake(with: peerID) initiateNoiseHandshake(with: peerID)
} }
} catch { } catch {
SecureLogger.log("❌ Failed to decrypt message from \(peerID): \(error)", SecureLogger.error("❌ Failed to decrypt message from \(peerID): \(error)")
category: SecureLogger.noise, level: .error)
} }
} }
@@ -1808,7 +1795,7 @@ final class BLEService: NSObject {
// MARK: - Helper Functions // MARK: - Helper Functions
private func sendLeave() { private func sendLeave() {
SecureLogger.log("👋 Sending leave announcement", category: SecureLogger.session, level: .debug) SecureLogger.debug("👋 Sending leave announcement", category: .session)
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.leave.rawValue, type: MessageType.leave.rawValue,
ttl: messageTTL, ttl: messageTTL,
@@ -1845,7 +1832,7 @@ final class BLEService: NSObject {
) )
guard let payload = announcement.encode() else { guard let payload = announcement.encode() else {
SecureLogger.log("❌ Failed to encode announce packet", category: SecureLogger.session, level: .error) SecureLogger.error("❌ Failed to encode announce packet", category: .session)
return return
} }
@@ -1862,7 +1849,7 @@ final class BLEService: NSObject {
// Sign the packet using the noise private key // Sign the packet using the noise private key
guard let signedPacket = noiseService.signPacket(packet) else { guard let signedPacket = noiseService.signPacket(packet) else {
SecureLogger.log("❌ Failed to sign announce packet", category: SecureLogger.security, level: .error) SecureLogger.error("❌ Failed to sign announce packet", category: .security)
return return
} }
@@ -1895,7 +1882,7 @@ final class BLEService: NSObject {
) )
broadcastPacket(packet) broadcastPacket(packet)
} catch { } catch {
SecureLogger.log("Failed to send delivery ACK: \(error)", category: SecureLogger.noise, level: .error) SecureLogger.error("Failed to send delivery ACK: \(error)")
} }
} else { } else {
// Queue for after handshake and initiate if needed // Queue for after handshake and initiate if needed
@@ -1904,8 +1891,7 @@ final class BLEService: NSObject {
self.pendingNoisePayloadsAfterHandshake[peerID, default: []].append(payload) self.pendingNoisePayloadsAfterHandshake[peerID, default: []].append(payload)
} }
if !noiseService.hasSession(with: peerID) { initiateNoiseHandshake(with: peerID) } if !noiseService.hasSession(with: peerID) { initiateNoiseHandshake(with: peerID) }
SecureLogger.log("🕒 Queued DELIVERED ack for \(peerID) until handshake completes", SecureLogger.debug("🕒 Queued DELIVERED ack for \(peerID) until handshake completes", category: .session)
category: SecureLogger.session, level: .debug)
} }
} }
@@ -1916,8 +1902,7 @@ final class BLEService: NSObject {
return list return list
} }
guard !payloads.isEmpty else { return } guard !payloads.isEmpty else { return }
SecureLogger.log("📤 Sending \(payloads.count) pending noise payloads to \(peerID) after handshake", SecureLogger.debug("📤 Sending \(payloads.count) pending noise payloads to \(peerID) after handshake", category: .session)
category: SecureLogger.session, level: .debug)
for payload in payloads { for payload in payloads {
do { do {
let encrypted = try noiseService.encrypt(payload, for: peerID) let encrypted = try noiseService.encrypt(payload, for: peerID)
@@ -1932,8 +1917,7 @@ final class BLEService: NSObject {
) )
broadcastPacket(packet) broadcastPacket(packet)
} catch { } catch {
SecureLogger.log("❌ Failed to send pending noise payload to \(peerID): \(error)", SecureLogger.error("❌ Failed to send pending noise payload to \(peerID): \(error)")
category: SecureLogger.noise, level: .error)
} }
} }
} }
@@ -2103,8 +2087,7 @@ final class BLEService: NSObject {
// Cleanup: remove peers that are not connected and past reachability retention // Cleanup: remove peers that are not connected and past reachability retention
if !peer.isConnected { if !peer.isConnected {
if age > retention { if age > retention {
SecureLogger.log("🗑️ Removing stale peer after reachability window: \(peerID) (\(peer.nickname))", SecureLogger.debug("🗑️ Removing stale peer after reachability window: \(peerID) (\(peer.nickname))", category: .session)
category: SecureLogger.session, level: .debug)
peers.removeValue(forKey: peerID) peers.removeValue(forKey: peerID)
removedOfflineCount += 1 removedOfflineCount += 1
} }
@@ -2395,8 +2378,7 @@ extension BLEService: CBCentralManagerDelegate {
peripheral.delegate = self peripheral.delegate = self
// Connect to the peripheral with options for faster connection // Connect to the peripheral with options for faster connection
SecureLogger.log("📱 Connect: \(advertisedName) [RSSI:\(rssiValue)]", SecureLogger.debug("📱 Connect: \(advertisedName) [RSSI:\(rssiValue)]", category: .session)
category: SecureLogger.session, level: .debug)
// Use connection options for faster reconnection // Use connection options for faster reconnection
let options: [String: Any] = [ let options: [String: Any] = [
@@ -2415,8 +2397,7 @@ extension BLEService: CBCentralManagerDelegate {
state.isConnecting && !state.isConnected else { return } state.isConnecting && !state.isConnected else { return }
// Connection timed out - cancel it // Connection timed out - cancel it
SecureLogger.log("⏱️ Timeout: \(advertisedName)", SecureLogger.debug("⏱️ Timeout: \(advertisedName)", category: .session)
category: SecureLogger.session, level: .debug)
central.cancelPeripheralConnection(peripheral) central.cancelPeripheralConnection(peripheral)
self.peripherals[peripheralID] = nil self.peripherals[peripheralID] = nil
self.recentConnectTimeouts[peripheralID] = Date() self.recentConnectTimeouts[peripheralID] = Date()
@@ -2449,7 +2430,7 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
failureCounts[peripheralID] = 0 failureCounts[peripheralID] = 0
recentConnectTimeouts.removeValue(forKey: peripheralID) recentConnectTimeouts.removeValue(forKey: peripheralID)
SecureLogger.log("✅ Connected: \(peripheral.name ?? "Unknown") [\(peripheralID)]", category: SecureLogger.session, level: .debug) SecureLogger.debug("✅ Connected: \(peripheral.name ?? "Unknown") [\(peripheralID)]", category: .session)
// Discover services // Discover services
peripheral.discoverServices([BLEService.serviceUUID]) peripheral.discoverServices([BLEService.serviceUUID])
@@ -2461,8 +2442,7 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
// Find the peer ID if we have it // Find the peer ID if we have it
let peerID = peripherals[peripheralID]?.peerID let peerID = peripherals[peripheralID]?.peerID
SecureLogger.log("📱 Disconnect: \(peerID ?? peripheralID)\(error != nil ? " (\(error!.localizedDescription))" : "")", SecureLogger.debug("📱 Disconnect: \(peerID ?? peripheralID)\(error != nil ? " (\(error!.localizedDescription))" : "")", category: .session)
category: SecureLogger.session, level: .debug)
// If disconnect carried an error (often timeout), apply short backoff to avoid thrash // If disconnect carried an error (often timeout), apply short backoff to avoid thrash
if error != nil { if error != nil {
@@ -2517,7 +2497,7 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
// Clean up the references // Clean up the references
peripherals.removeValue(forKey: peripheralID) peripherals.removeValue(forKey: peripheralID)
SecureLogger.log("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: SecureLogger.session, level: .error) SecureLogger.error("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: .session)
failureCounts[peripheralID, default: 0] += 1 failureCounts[peripheralID, default: 0] += 1
// Try next candidate // Try next candidate
bleQueue.async { [weak self] in self?.tryConnectFromQueue() } bleQueue.async { [weak self] in self?.tryConnectFromQueue() }
@@ -2590,7 +2570,7 @@ extension BLEService {
] ]
central.connect(peripheral, options: options) central.connect(peripheral, options: options)
lastGlobalConnectAttempt = Date() lastGlobalConnectAttempt = Date()
SecureLogger.log("⏩ Queue connect: \(candidate.name) [RSSI:\(candidate.rssi)]", category: SecureLogger.session, level: .debug) SecureLogger.debug("⏩ Queue connect: \(candidate.name) [RSSI:\(candidate.rssi)]", category: .session)
} }
} }
@@ -2635,7 +2615,7 @@ extension BLEService {
extension BLEService: CBPeripheralDelegate { extension BLEService: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
if let error = error { if let error = error {
SecureLogger.log("❌ Error discovering services for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: SecureLogger.session, level: .error) SecureLogger.error("❌ Error discovering services for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session)
// Retry service discovery after a delay // Retry service discovery after a delay
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
guard peripheral.state == .connected else { return } guard peripheral.state == .connected else { return }
@@ -2645,7 +2625,7 @@ extension BLEService: CBPeripheralDelegate {
} }
guard let services = peripheral.services else { guard let services = peripheral.services else {
SecureLogger.log("⚠️ No services discovered for \(peripheral.name ?? "Unknown")", category: SecureLogger.session, level: .warning) SecureLogger.warning("⚠️ No services discovered for \(peripheral.name ?? "Unknown")", category: .session)
return return
} }
@@ -2661,12 +2641,12 @@ extension BLEService: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
if let error = error { if let error = error {
SecureLogger.log("❌ Error discovering characteristics for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: SecureLogger.session, level: .error) SecureLogger.error("❌ Error discovering characteristics for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session)
return return
} }
guard let characteristic = service.characteristics?.first(where: { $0.uuid == BLEService.characteristicUUID }) else { guard let characteristic = service.characteristics?.first(where: { $0.uuid == BLEService.characteristicUUID }) else {
SecureLogger.log("⚠️ No matching characteristic found for \(peripheral.name ?? "Unknown")", category: SecureLogger.session, level: .warning) SecureLogger.warning("⚠️ No matching characteristic found for \(peripheral.name ?? "Unknown")", category: .session)
return return
} }
@@ -2683,7 +2663,7 @@ extension BLEService: CBPeripheralDelegate {
// Verify characteristic supports reliable writes // Verify characteristic supports reliable writes
if !characteristic.properties.contains(.write) { if !characteristic.properties.contains(.write) {
SecureLogger.log("⚠️ Characteristic doesn't support reliable writes (withResponse)!", category: SecureLogger.session, level: .warning) SecureLogger.warning("⚠️ Characteristic doesn't support reliable writes (withResponse)!", category: .session)
} }
// Store characteristic in our consolidated structure // Store characteristic in our consolidated structure
@@ -2696,7 +2676,7 @@ extension BLEService: CBPeripheralDelegate {
// Subscribe for notifications // Subscribe for notifications
if characteristic.properties.contains(.notify) { if characteristic.properties.contains(.notify) {
peripheral.setNotifyValue(true, for: characteristic) peripheral.setNotifyValue(true, for: characteristic)
SecureLogger.log("🔔 Subscribed to notifications from \(peripheral.name ?? "Unknown")", category: SecureLogger.session, level: .debug) SecureLogger.debug("🔔 Subscribed to notifications from \(peripheral.name ?? "Unknown")", category: .session)
// Send announce after subscription is confirmed (force send for new connection) // Send announce after subscription is confirmed (force send for new connection)
messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostSubscribeAnnounceDelaySeconds) { [weak self] in messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostSubscribeAnnounceDelaySeconds) { [weak self] in
@@ -2707,18 +2687,18 @@ extension BLEService: CBPeripheralDelegate {
self?.rebroadcastRecentAnnounces() self?.rebroadcastRecentAnnounces()
} }
} else { } else {
SecureLogger.log("⚠️ Characteristic does not support notifications", category: SecureLogger.session, level: .warning) SecureLogger.warning("⚠️ Characteristic does not support notifications", category: .session)
} }
} }
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
if let error = error { if let error = error {
SecureLogger.log("❌ Error receiving notification: \(error.localizedDescription)", category: SecureLogger.session, level: .error) SecureLogger.error("❌ Error receiving notification: \(error.localizedDescription)", category: .session)
return return
} }
guard let data = characteristic.value else { guard let data = characteristic.value else {
SecureLogger.log("⚠️ No data in notification", category: SecureLogger.session, level: .warning) SecureLogger.warning("⚠️ No data in notification", category: .session)
return return
} }
@@ -2728,8 +2708,7 @@ extension BLEService: CBPeripheralDelegate {
guard let packet = BinaryProtocol.decode(data) else { guard let packet = BinaryProtocol.decode(data) else {
// Avoid dumping entire payload; log size and short prefix for diagnostics // Avoid dumping entire payload; log size and short prefix for diagnostics
let prefix = data.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ") let prefix = data.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")
SecureLogger.log("❌ Failed to decode notification packet (len=\(data.count), prefix=\(prefix))", SecureLogger.error("❌ Failed to decode notification packet (len=\(data.count), prefix=\(prefix))", category: .session)
category: SecureLogger.session, level: .error)
return return
} }
@@ -2737,7 +2716,7 @@ extension BLEService: CBPeripheralDelegate {
let senderID = packet.senderID.hexEncodedString() let senderID = packet.senderID.hexEncodedString()
// Only log non-announce packets // Only log non-announce packets
if packet.type != MessageType.announce.rawValue { if packet.type != MessageType.announce.rawValue {
SecureLogger.log("📦 Decoded notification packet type: \(packet.type) from sender: \(senderID)", category: SecureLogger.session, level: .debug) SecureLogger.debug("📦 Decoded notification packet type: \(packet.type) from sender: \(senderID)", category: .session)
} }
let peripheralUUID = peripheral.identifier.uuidString let peripheralUUID = peripheral.identifier.uuidString
@@ -2775,10 +2754,10 @@ extension BLEService: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
if let error = error { if let error = error {
SecureLogger.log("❌ Write failed to \(peripheral.name ?? peripheral.identifier.uuidString): \(error.localizedDescription)", category: SecureLogger.session, level: .error) SecureLogger.error("❌ Write failed to \(peripheral.name ?? peripheral.identifier.uuidString): \(error.localizedDescription)", category: .session)
// Don't retry - just log the error // Don't retry - just log the error
} else { } else {
SecureLogger.log("✅ Write confirmed to \(peripheral.name ?? peripheral.identifier.uuidString)", category: SecureLogger.session, level: .debug) SecureLogger.debug("✅ Write confirmed to \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session)
} }
} }
@@ -2788,14 +2767,14 @@ extension BLEService: CBPeripheralDelegate {
} }
func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) { func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) {
SecureLogger.log("⚠️ Services modified for \(peripheral.name ?? peripheral.identifier.uuidString)", category: SecureLogger.session, level: .warning) SecureLogger.warning("⚠️ Services modified for \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session)
// Check if our service was invalidated (peer app quit) // Check if our service was invalidated (peer app quit)
let hasOurService = peripheral.services?.contains { $0.uuid == BLEService.serviceUUID } ?? false let hasOurService = peripheral.services?.contains { $0.uuid == BLEService.serviceUUID } ?? false
if !hasOurService { if !hasOurService {
// Service is gone - disconnect // Service is gone - disconnect
SecureLogger.log("❌ BitChat service removed - disconnecting from \(peripheral.name ?? peripheral.identifier.uuidString)", category: SecureLogger.session, level: .warning) SecureLogger.warning("❌ BitChat service removed - disconnecting from \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session)
centralManager?.cancelPeripheralConnection(peripheral) centralManager?.cancelPeripheralConnection(peripheral)
} else { } else {
// Try to rediscover // Try to rediscover
@@ -2805,9 +2784,9 @@ extension BLEService: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
if let error = error { if let error = error {
SecureLogger.log("❌ Error updating notification state: \(error.localizedDescription)", category: SecureLogger.session, level: .error) SecureLogger.error("❌ Error updating notification state: \(error.localizedDescription)", category: .session)
} else { } else {
SecureLogger.log("🔔 Notification state updated for \(peripheral.name ?? peripheral.identifier.uuidString): \(characteristic.isNotifying ? "ON" : "OFF")", category: SecureLogger.session, level: .debug) SecureLogger.debug("🔔 Notification state updated for \(peripheral.name ?? peripheral.identifier.uuidString): \(characteristic.isNotifying ? "ON" : "OFF")", category: .session)
// If notifications are now on, send an announce to ensure this peer knows about us // If notifications are now on, send an announce to ensure this peer knows about us
if characteristic.isNotifying { if characteristic.isNotifying {
@@ -2822,7 +2801,7 @@ extension BLEService: CBPeripheralDelegate {
extension BLEService: CBPeripheralManagerDelegate { extension BLEService: CBPeripheralManagerDelegate {
func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) { func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
SecureLogger.log("📡 Peripheral manager state: \(peripheral.state.rawValue)", category: SecureLogger.session, level: .debug) SecureLogger.debug("📡 Peripheral manager state: \(peripheral.state.rawValue)", category: .session)
if peripheral.state == .poweredOn { if peripheral.state == .poweredOn {
// Remove all services first to ensure clean state // Remove all services first to ensure clean state
@@ -2841,28 +2820,28 @@ extension BLEService: CBPeripheralManagerDelegate {
service.characteristics = [characteristic!] service.characteristics = [characteristic!]
// Add service (advertising will start in didAdd delegate) // Add service (advertising will start in didAdd delegate)
SecureLogger.log("🔧 Adding BLE service...", category: SecureLogger.session, level: .debug) SecureLogger.debug("🔧 Adding BLE service...", category: .session)
peripheral.add(service) peripheral.add(service)
} }
} }
func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) { func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) {
if let error = error { if let error = error {
SecureLogger.log("❌ Failed to add service: \(error.localizedDescription)", category: SecureLogger.session, level: .error) SecureLogger.error("❌ Failed to add service: \(error.localizedDescription)", category: .session)
return return
} }
SecureLogger.log("✅ Service added successfully, starting advertising", category: SecureLogger.session, level: .debug) SecureLogger.debug("✅ Service added successfully, starting advertising", category: .session)
// Start advertising after service is confirmed added // Start advertising after service is confirmed added
let adData = buildAdvertisementData() let adData = buildAdvertisementData()
peripheral.startAdvertising(adData) peripheral.startAdvertising(adData)
SecureLogger.log("📡 Started advertising (LocalName: \((adData[CBAdvertisementDataLocalNameKey] as? String) != nil ? "on" : "off"), ID: \(myPeerID.prefix(8))…)", category: SecureLogger.session, level: .debug) SecureLogger.debug("📡 Started advertising (LocalName: \((adData[CBAdvertisementDataLocalNameKey] as? String) != nil ? "on" : "off"), ID: \(myPeerID.prefix(8))…)", category: .session)
} }
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) { func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) {
SecureLogger.log("📥 Central subscribed: \(central.identifier.uuidString)", category: SecureLogger.session, level: .debug) SecureLogger.debug("📥 Central subscribed: \(central.identifier.uuidString)", category: .session)
subscribedCentrals.append(central) subscribedCentrals.append(central)
// Send announce to the newly subscribed central after a small delay to avoid overwhelming // Send announce to the newly subscribed central after a small delay to avoid overwhelming
messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in
@@ -2875,12 +2854,12 @@ extension BLEService: CBPeripheralManagerDelegate {
} }
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) { func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) {
SecureLogger.log("📤 Central unsubscribed: \(central.identifier.uuidString)", category: SecureLogger.session, level: .debug) SecureLogger.debug("📤 Central unsubscribed: \(central.identifier.uuidString)", category: .session)
subscribedCentrals.removeAll { $0.identifier == central.identifier } subscribedCentrals.removeAll { $0.identifier == central.identifier }
// Ensure we're still advertising for other devices to find us // Ensure we're still advertising for other devices to find us
if peripheral.isAdvertising == false { if peripheral.isAdvertising == false {
SecureLogger.log("📡 Restarting advertising after central unsubscribed", category: SecureLogger.session, level: .debug) SecureLogger.debug("📡 Restarting advertising after central unsubscribed", category: .session)
peripheral.startAdvertising(buildAdvertisementData()) peripheral.startAdvertising(buildAdvertisementData())
} }
@@ -2914,7 +2893,7 @@ extension BLEService: CBPeripheralManagerDelegate {
} }
func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) { func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) {
SecureLogger.log("📤 Peripheral manager ready to send more notifications", category: SecureLogger.session, level: .debug) SecureLogger.debug("📤 Peripheral manager ready to send more notifications", category: .session)
// Retry pending notifications now that queue has space // Retry pending notifications now that queue has space
collectionsQueue.async(flags: .barrier) { [weak self] in collectionsQueue.async(flags: .barrier) { [weak self] in
@@ -2933,12 +2912,10 @@ extension BLEService: CBPeripheralManagerDelegate {
if !success { if !success {
// Still full, re-queue // Still full, re-queue
self.pendingNotifications.append((data: data, centrals: centrals)) self.pendingNotifications.append((data: data, centrals: centrals))
SecureLogger.log("⚠️ Notification queue still full, re-queuing", SecureLogger.debug("⚠️ Notification queue still full, re-queuing", category: .session)
category: SecureLogger.session, level: .debug)
break // Stop trying, wait for next ready callback break // Stop trying, wait for next ready callback
} else { } else {
SecureLogger.log("✅ Sent pending notification from retry queue", SecureLogger.debug("✅ Sent pending notification from retry queue", category: .session)
category: SecureLogger.session, level: .debug)
} }
} else { } else {
// Broadcast to all // Broadcast to all
@@ -2952,8 +2929,7 @@ extension BLEService: CBPeripheralManagerDelegate {
} }
if !self.pendingNotifications.isEmpty { if !self.pendingNotifications.isEmpty {
SecureLogger.log("📋 Still have \(self.pendingNotifications.count) pending notifications", SecureLogger.debug("📋 Still have \(self.pendingNotifications.count) pending notifications", category: .session)
category: SecureLogger.session, level: .debug)
} }
} }
} }
@@ -2961,7 +2937,7 @@ extension BLEService: CBPeripheralManagerDelegate {
func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) { func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) {
// Suppress logs for single write requests to reduce noise // Suppress logs for single write requests to reduce noise
if requests.count > 1 { if requests.count > 1 {
SecureLogger.log("📥 Received \(requests.count) write requests from central", category: SecureLogger.session, level: .debug) SecureLogger.debug("📥 Received \(requests.count) write requests from central", category: .session)
} }
// IMPORTANT: Respond immediately to prevent timeouts! // IMPORTANT: Respond immediately to prevent timeouts!
@@ -3000,7 +2976,7 @@ extension BLEService: CBPeripheralManagerDelegate {
if combined.count >= 2 { if combined.count >= 2 {
let peekType = combined[1] let peekType = combined[1]
if peekType != MessageType.announce.rawValue { if peekType != MessageType.announce.rawValue {
SecureLogger.log("📥 Accumulated write from central \(centralUUID): size=\(combined.count) (+\(appendedBytes)) bytes (type=\(peekType)), offsets=\(offsets)", category: SecureLogger.session, level: .debug) SecureLogger.debug("📥 Accumulated write from central \(centralUUID): size=\(combined.count) (+\(appendedBytes)) bytes (type=\(peekType)), offsets=\(offsets)", category: .session)
} }
} }
@@ -3010,7 +2986,7 @@ extension BLEService: CBPeripheralManagerDelegate {
pendingWriteBuffers.removeValue(forKey: centralUUID) pendingWriteBuffers.removeValue(forKey: centralUUID)
let senderID = packet.senderID.hexEncodedString() let senderID = packet.senderID.hexEncodedString()
if packet.type != MessageType.announce.rawValue { if packet.type != MessageType.announce.rawValue {
SecureLogger.log("📦 Decoded (combined) packet type: \(packet.type) from sender: \(senderID)", category: SecureLogger.session, level: .debug) SecureLogger.debug("📦 Decoded (combined) packet type: \(packet.type) from sender: \(senderID)", category: .session)
} }
if !subscribedCentrals.contains(sorted[0].central) { if !subscribedCentrals.contains(sorted[0].central) {
subscribedCentrals.append(sorted[0].central) subscribedCentrals.append(sorted[0].central)
@@ -3035,12 +3011,12 @@ extension BLEService: CBPeripheralManagerDelegate {
// If buffer grows suspiciously large, reset to avoid memory leak // If buffer grows suspiciously large, reset to avoid memory leak
if combined.count > TransportConfig.blePendingWriteBufferCapBytes { // cap for safety if combined.count > TransportConfig.blePendingWriteBufferCapBytes { // cap for safety
pendingWriteBuffers.removeValue(forKey: centralUUID) pendingWriteBuffers.removeValue(forKey: centralUUID)
SecureLogger.log("⚠️ Dropping oversized pending write buffer (\(combined.count) bytes) for central \(centralUUID)", category: SecureLogger.session, level: .warning) SecureLogger.warning("⚠️ Dropping oversized pending write buffer (\(combined.count) bytes) for central \(centralUUID)", category: .session)
} }
// If this was a single short write and still failed, log the raw chunk for debugging // If this was a single short write and still failed, log the raw chunk for debugging
if !hasMultiple, let only = sorted.first, let raw = only.value { if !hasMultiple, let only = sorted.first, let raw = only.value {
let prefix = raw.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ") let prefix = raw.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")
SecureLogger.log("❌ Failed to decode packet from central (len=\(raw.count), prefix=\(prefix))", category: SecureLogger.session, level: .error) SecureLogger.error("❌ Failed to decode packet from central (len=\(raw.count), prefix=\(prefix))", category: .session)
} }
} }
} }
+20 -18
View File
@@ -17,13 +17,15 @@ enum CommandResult {
/// Processes chat commands in a focused, efficient way /// Processes chat commands in a focused, efficient way
@MainActor @MainActor
class CommandProcessor { final class CommandProcessor {
weak var chatViewModel: ChatViewModel? weak var chatViewModel: ChatViewModel?
weak var meshService: Transport? weak var meshService: Transport?
private let identityManager: SecureIdentityStateManagerProtocol
init(chatViewModel: ChatViewModel? = nil, meshService: Transport? = nil) { init(chatViewModel: ChatViewModel? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {
self.chatViewModel = chatViewModel self.chatViewModel = chatViewModel
self.meshService = meshService self.meshService = meshService
self.identityManager = identityManager
} }
/// Process a command string /// Process a command string
@@ -50,9 +52,9 @@ class CommandProcessor {
case "/clear": case "/clear":
return handleClear() return handleClear()
case "/hug": case "/hug":
return handleEmote(args, action: "hugs", emoji: "🫂") return handleEmote(args, command: "hug", action: "hugs", emoji: "🫂")
case "/slap": case "/slap":
return handleEmote(args, action: "slaps", emoji: "🐟", suffix: " around a bit with a large trout") return handleEmote(args, command: "slap", action: "slaps", emoji: "🐟", suffix: " around a bit with a large trout")
case "/block": case "/block":
return handleBlock(args) return handleBlock(args)
case "/unblock": case "/unblock":
@@ -129,17 +131,17 @@ class CommandProcessor {
return .handled return .handled
} }
private func handleEmote(_ args: String, action: String, emoji: String, suffix: String = "") -> CommandResult { private func handleEmote(_ args: String, command: String, action: String, emoji: String, suffix: String = "") -> CommandResult {
let targetName = args.trimmingCharacters(in: .whitespaces) let targetName = args.trimmingCharacters(in: .whitespaces)
guard !targetName.isEmpty else { guard !targetName.isEmpty else {
return .error(message: "usage: /\(action) <nickname>") return .error(message: "usage: /\(command) <nickname>")
} }
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
guard let targetPeerID = chatViewModel?.getPeerIDForNickname(nickname), guard let targetPeerID = chatViewModel?.getPeerIDForNickname(nickname),
let myNickname = chatViewModel?.nickname else { let myNickname = chatViewModel?.nickname else {
return .error(message: "cannot \(action) \(nickname): not found") return .error(message: "cannot \(command) \(nickname): not found")
} }
let emoteContent = "* \(emoji) \(myNickname) \(action) \(nickname)\(suffix) *" let emoteContent = "* \(emoji) \(myNickname) \(action) \(nickname)\(suffix) *"
@@ -189,7 +191,7 @@ class CommandProcessor {
} }
// Geohash blocked names (prefer visible display names; fallback to #suffix) // Geohash blocked names (prefer visible display names; fallback to #suffix)
let geoBlocked = Array(SecureIdentityStateManager.shared.getBlockedNostrPubkeys()) let geoBlocked = Array(identityManager.getBlockedNostrPubkeys())
var geoNames: [String] = [] var geoNames: [String] = []
if let vm = chatViewModel { if let vm = chatViewModel {
let visible = vm.visibleGeohashPeople() let visible = vm.visibleGeohashPeople()
@@ -213,14 +215,14 @@ class CommandProcessor {
if let peerID = chatViewModel?.getPeerIDForNickname(nickname), if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
let fingerprint = meshService?.getFingerprint(for: peerID) { let fingerprint = meshService?.getFingerprint(for: peerID) {
if SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) { if identityManager.isBlocked(fingerprint: fingerprint) {
return .success(message: "\(nickname) is already blocked") return .success(message: "\(nickname) is already blocked")
} }
// Block the user (mesh/noise identity) // Block the user (mesh/noise identity)
if var identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprint) { if var identity = identityManager.getSocialIdentity(for: fingerprint) {
identity.isBlocked = true identity.isBlocked = true
identity.isFavorite = false identity.isFavorite = false
SecureIdentityStateManager.shared.updateSocialIdentity(identity) identityManager.updateSocialIdentity(identity)
} else { } else {
let blockedIdentity = SocialIdentity( let blockedIdentity = SocialIdentity(
fingerprint: fingerprint, fingerprint: fingerprint,
@@ -231,16 +233,16 @@ class CommandProcessor {
isBlocked: true, isBlocked: true,
notes: nil notes: nil
) )
SecureIdentityStateManager.shared.updateSocialIdentity(blockedIdentity) identityManager.updateSocialIdentity(blockedIdentity)
} }
return .success(message: "blocked \(nickname). you will no longer receive messages from them") return .success(message: "blocked \(nickname). you will no longer receive messages from them")
} }
// Mesh lookup failed; try geohash (Nostr) participant by display name // Mesh lookup failed; try geohash (Nostr) participant by display name
if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) { if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) {
if SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: pub) { if identityManager.isNostrBlocked(pubkeyHexLowercased: pub) {
return .success(message: "\(nickname) is already blocked") return .success(message: "\(nickname) is already blocked")
} }
SecureIdentityStateManager.shared.setNostrBlocked(pub, isBlocked: true) identityManager.setNostrBlocked(pub, isBlocked: true)
return .success(message: "blocked \(nickname) in geohash chats") return .success(message: "blocked \(nickname) in geohash chats")
} }
@@ -257,18 +259,18 @@ class CommandProcessor {
if let peerID = chatViewModel?.getPeerIDForNickname(nickname), if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
let fingerprint = meshService?.getFingerprint(for: peerID) { let fingerprint = meshService?.getFingerprint(for: peerID) {
if !SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) { if !identityManager.isBlocked(fingerprint: fingerprint) {
return .success(message: "\(nickname) is not blocked") return .success(message: "\(nickname) is not blocked")
} }
SecureIdentityStateManager.shared.setBlocked(fingerprint, isBlocked: false) identityManager.setBlocked(fingerprint, isBlocked: false)
return .success(message: "unblocked \(nickname)") return .success(message: "unblocked \(nickname)")
} }
// Try geohash unblock // Try geohash unblock
if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) { if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) {
if !SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: pub) { if !identityManager.isNostrBlocked(pubkeyHexLowercased: pub) {
return .success(message: "\(nickname) is not blocked") return .success(message: "\(nickname) is not blocked")
} }
SecureIdentityStateManager.shared.setNostrBlocked(pub, isBlocked: false) identityManager.setNostrBlocked(pub, isBlocked: false)
return .success(message: "unblocked \(nickname) in geohash chats") return .success(message: "unblocked \(nickname) in geohash chats")
} }
return .error(message: "cannot unblock \(nickname): not found") return .error(message: "cannot unblock \(nickname): not found")
@@ -3,7 +3,7 @@ import Combine
/// Manages persistent favorite relationships between peers /// Manages persistent favorite relationships between peers
@MainActor @MainActor
class FavoritesPersistenceService: ObservableObject { final class FavoritesPersistenceService: ObservableObject {
struct FavoriteRelationship: Codable { struct FavoriteRelationship: Codable {
let peerNoisePublicKey: Data let peerNoisePublicKey: Data
@@ -13,12 +13,16 @@ class FavoritesPersistenceService: ObservableObject {
let theyFavoritedUs: Bool let theyFavoritedUs: Bool
let favoritedAt: Date let favoritedAt: Date
let lastUpdated: Date let lastUpdated: Date
// Track what we last sent as OUR npub to this peer, to avoid resending unless it changes
// Note: we do not track which npub we last sent to them; sending happens only on favorite toggle
var isMutual: Bool { var isMutual: Bool {
isFavorite && theyFavoritedUs isFavorite && theyFavoritedUs
} }
} }
// We intentionally do not track when we last sent our npub; sending happens only on favorite toggle.
private static let storageKey = "chat.bitchat.favorites" private static let storageKey = "chat.bitchat.favorites"
private static let keychainService = "chat.bitchat.favorites" private static let keychainService = "chat.bitchat.favorites"
@@ -47,8 +51,7 @@ class FavoritesPersistenceService: ObservableObject {
peerNostrPublicKey: String? = nil, peerNostrPublicKey: String? = nil,
peerNickname: String peerNickname: String
) { ) {
SecureLogger.log("⭐️ Adding favorite: \(peerNickname) (\(peerNoisePublicKey.hexEncodedString()))", SecureLogger.info("⭐️ Adding favorite: \(peerNickname) (\(peerNoisePublicKey.hexEncodedString()))", category: .session)
category: SecureLogger.session, level: .info)
let existing = favorites[peerNoisePublicKey] let existing = favorites[peerNoisePublicKey]
@@ -64,8 +67,7 @@ class FavoritesPersistenceService: ObservableObject {
// Log if this creates a mutual favorite // Log if this creates a mutual favorite
if relationship.isMutual { if relationship.isMutual {
SecureLogger.log("💕 Mutual favorite relationship established with \(peerNickname)!", SecureLogger.info("💕 Mutual favorite relationship established with \(peerNickname)!", category: .session)
category: SecureLogger.session, level: .info)
} }
favorites[peerNoisePublicKey] = relationship favorites[peerNoisePublicKey] = relationship
@@ -83,8 +85,7 @@ class FavoritesPersistenceService: ObservableObject {
func removeFavorite(peerNoisePublicKey: Data) { func removeFavorite(peerNoisePublicKey: Data) {
guard let existing = favorites[peerNoisePublicKey] else { return } guard let existing = favorites[peerNoisePublicKey] else { return }
SecureLogger.log("⭐️ Removing favorite: \(existing.peerNickname) (\(peerNoisePublicKey.hexEncodedString()))", SecureLogger.info("⭐️ Removing favorite: \(existing.peerNickname) (\(peerNoisePublicKey.hexEncodedString()))", category: .session)
category: SecureLogger.session, level: .info)
// If they still favorite us, keep the record but mark us as not favoriting // If they still favorite us, keep the record but mark us as not favoriting
if existing.theyFavoritedUs { if existing.theyFavoritedUs {
@@ -125,8 +126,7 @@ class FavoritesPersistenceService: ObservableObject {
let existing = favorites[peerNoisePublicKey] let existing = favorites[peerNoisePublicKey]
let displayName = peerNickname ?? existing?.peerNickname ?? "Unknown" let displayName = peerNickname ?? existing?.peerNickname ?? "Unknown"
SecureLogger.log("📨 Received favorite notification: \(displayName) \(favorited ? "favorited" : "unfavorited") us", SecureLogger.info("📨 Received favorite notification: \(displayName) \(favorited ? "favorited" : "unfavorited") us", category: .session)
category: SecureLogger.session, level: .info)
let relationship = FavoriteRelationship( let relationship = FavoriteRelationship(
peerNoisePublicKey: peerNoisePublicKey, peerNoisePublicKey: peerNoisePublicKey,
@@ -147,8 +147,7 @@ class FavoritesPersistenceService: ObservableObject {
// Check if this creates a mutual favorite // Check if this creates a mutual favorite
if relationship.isMutual { if relationship.isMutual {
SecureLogger.log("💕 Mutual favorite relationship established with \(displayName)!", SecureLogger.info("💕 Mutual favorite relationship established with \(displayName)!", category: .session)
category: SecureLogger.session, level: .info)
} }
} }
@@ -240,15 +239,13 @@ class FavoritesPersistenceService: ObservableObject {
/// Update noise public key when peer reconnects with new ID /// Update noise public key when peer reconnects with new ID
func updateNoisePublicKey(from oldKey: Data, to newKey: Data, peerNickname: String) { func updateNoisePublicKey(from oldKey: Data, to newKey: Data, peerNickname: String) {
guard let existing = favorites[oldKey] else { guard let existing = favorites[oldKey] else {
SecureLogger.log("⚠️ Cannot update noise key - no favorite found for \(oldKey.hexEncodedString())", SecureLogger.warning("⚠️ Cannot update noise key - no favorite found for \(oldKey.hexEncodedString())", category: .session)
category: SecureLogger.session, level: .warning)
return return
} }
// Check if we already have a favorite with the new key // Check if we already have a favorite with the new key
if favorites[newKey] != nil { if favorites[newKey] != nil {
SecureLogger.log("⚠️ Favorite already exists with new key \(newKey.hexEncodedString()), removing old entry", SecureLogger.warning("⚠️ Favorite already exists with new key \(newKey.hexEncodedString()), removing old entry", category: .session)
category: SecureLogger.session, level: .warning)
favorites.removeValue(forKey: oldKey) favorites.removeValue(forKey: oldKey)
saveFavorites() saveFavorites()
return return
@@ -302,7 +299,7 @@ class FavoritesPersistenceService: ObservableObject {
/// Clear all favorites - used for panic mode /// Clear all favorites - used for panic mode
func clearAllFavorites() { func clearAllFavorites() {
SecureLogger.log("🧹 Clearing all favorites (panic mode)", category: SecureLogger.session, level: .warning) SecureLogger.warning("🧹 Clearing all favorites (panic mode)", category: .session)
favorites.removeAll() favorites.removeAll()
saveFavorites() saveFavorites()
@@ -336,7 +333,7 @@ class FavoritesPersistenceService: ObservableObject {
// Successfully saved favorites // Successfully saved favorites
} catch { } catch {
SecureLogger.log("Failed to save favorites: \(error)", category: SecureLogger.session, level: .error) SecureLogger.error("Failed to save favorites: \(error)", category: .session)
} }
} }
@@ -354,14 +351,12 @@ class FavoritesPersistenceService: ObservableObject {
let decoder = JSONDecoder() let decoder = JSONDecoder()
let relationships = try decoder.decode([FavoriteRelationship].self, from: data) let relationships = try decoder.decode([FavoriteRelationship].self, from: data)
SecureLogger.log("✅ Loaded \(relationships.count) favorite relationships", SecureLogger.info("✅ Loaded \(relationships.count) favorite relationships", category: .session)
category: SecureLogger.session, level: .info)
// Log Nostr public key info // Log Nostr public key info
for relationship in relationships { for relationship in relationships {
if relationship.peerNostrPublicKey == nil { if relationship.peerNostrPublicKey == nil {
SecureLogger.log("⚠️ No Nostr public key stored for '\(relationship.peerNickname)'", SecureLogger.warning("⚠️ No Nostr public key stored for '\(relationship.peerNickname)'", category: .session)
category: SecureLogger.session, level: .warning)
} }
} }
@@ -372,8 +367,7 @@ class FavoritesPersistenceService: ObservableObject {
for relationship in relationships { for relationship in relationships {
// Check for duplicates by public key (the actual unique identifier) // Check for duplicates by public key (the actual unique identifier)
if let existing = seenPublicKeys[relationship.peerNoisePublicKey] { if let existing = seenPublicKeys[relationship.peerNoisePublicKey] {
SecureLogger.log("⚠️ Duplicate favorite found for public key \(relationship.peerNoisePublicKey.hexEncodedString()) - nicknames: '\(existing.peerNickname)' vs '\(relationship.peerNickname)'", SecureLogger.warning("⚠️ Duplicate favorite found for public key \(relationship.peerNoisePublicKey.hexEncodedString()) - nicknames: '\(existing.peerNickname)' vs '\(relationship.peerNickname)'", category: .session)
category: SecureLogger.session, level: .warning)
// Keep the most recent or most complete relationship // Keep the most recent or most complete relationship
if relationship.lastUpdated > existing.lastUpdated || if relationship.lastUpdated > existing.lastUpdated ||
@@ -414,7 +408,7 @@ class FavoritesPersistenceService: ObservableObject {
// Log loaded relationships // Log loaded relationships
// Loaded relationships successfully // Loaded relationships successfully
} catch { } catch {
SecureLogger.log("Failed to load favorites: \(error)", category: SecureLogger.session, level: .error) SecureLogger.error("Failed to load favorites: \(error)", category: .session)
} }
} }
} }
+22 -16
View File
@@ -8,18 +8,24 @@
import Foundation import Foundation
import Security import Security
import os.log
class KeychainManager { protocol KeychainManagerProtocol {
static let shared = KeychainManager() func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool
func getIdentityKey(forKey key: String) -> Data?
func deleteIdentityKey(forKey key: String) -> Bool
func deleteAllKeychainData() -> Bool
func secureClear(_ data: inout Data)
func secureClear(_ string: inout String)
func verifyIdentityKeyExists() -> Bool
}
final class KeychainManager: KeychainManagerProtocol {
// Use consistent service name for all keychain items // Use consistent service name for all keychain items
private let service = "chat.bitchat" private let service = "chat.bitchat"
private let appGroup = "group.chat.bitchat" private let appGroup = "group.chat.bitchat"
private init() {}
private func isSandboxed() -> Bool { private func isSandboxed() -> Bool {
#if os(macOS) #if os(macOS)
// More robust sandbox detection using multiple methods // More robust sandbox detection using multiple methods
@@ -53,7 +59,7 @@ class KeychainManager {
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool { func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
let fullKey = "identity_\(key)" let fullKey = "identity_\(key)"
let result = saveData(keyData, forKey: fullKey) let result = saveData(keyData, forKey: fullKey)
SecureLogger.logKeyOperation("save", keyType: key, success: result) SecureLogger.logKeyOperation(.save, keyType: key, success: result)
return result return result
} }
@@ -64,7 +70,7 @@ class KeychainManager {
func deleteIdentityKey(forKey key: String) -> Bool { func deleteIdentityKey(forKey key: String) -> Bool {
let result = delete(forKey: "identity_\(key)") let result = delete(forKey: "identity_\(key)")
SecureLogger.logKeyOperation("delete", keyType: key, success: result) SecureLogger.logKeyOperation(.delete, keyType: key, success: result)
return result return result
} }
@@ -113,9 +119,9 @@ class KeychainManager {
if status == errSecSuccess { return true } if status == errSecSuccess { return true }
if status == -34018 && !triedWithoutGroup { if status == -34018 && !triedWithoutGroup {
SecureLogger.logError(NSError(domain: "Keychain", code: -34018), context: "Missing keychain entitlement", category: SecureLogger.keychain) SecureLogger.error(NSError(domain: "Keychain", code: -34018), context: "Missing keychain entitlement", category: .keychain)
} else if status != errSecDuplicateItem { } else if status != errSecDuplicateItem {
SecureLogger.logError(NSError(domain: "Keychain", code: Int(status)), context: "Error saving to keychain", category: SecureLogger.keychain) SecureLogger.error(NSError(domain: "Keychain", code: Int(status)), context: "Error saving to keychain", category: .keychain)
} }
return false return false
} }
@@ -151,7 +157,7 @@ class KeychainManager {
if status == errSecSuccess { return result as? Data } if status == errSecSuccess { return result as? Data }
if status == -34018 { if status == -34018 {
SecureLogger.logError(NSError(domain: "Keychain", code: -34018), context: "Missing keychain entitlement", category: SecureLogger.keychain) SecureLogger.error(NSError(domain: "Keychain", code: -34018), context: "Missing keychain entitlement", category: .keychain)
} }
return nil return nil
} }
@@ -198,7 +204,7 @@ class KeychainManager {
// Delete ALL keychain data for panic mode // Delete ALL keychain data for panic mode
func deleteAllKeychainData() -> Bool { func deleteAllKeychainData() -> Bool {
SecureLogger.log("Panic mode - deleting all keychain data", category: SecureLogger.security, level: .warning) SecureLogger.warning("Panic mode - deleting all keychain data", category: .security)
var totalDeleted = 0 var totalDeleted = 0
@@ -261,7 +267,7 @@ class KeychainManager {
let deleteStatus = SecItemDelete(deleteQuery as CFDictionary) let deleteStatus = SecItemDelete(deleteQuery as CFDictionary)
if deleteStatus == errSecSuccess { if deleteStatus == errSecSuccess {
totalDeleted += 1 totalDeleted += 1
SecureLogger.log("Deleted keychain item: \(account) from \(service)", category: SecureLogger.keychain, level: .info) SecureLogger.info("Deleted keychain item: \(account) from \(service)", category: .keychain)
} }
} }
} }
@@ -303,7 +309,7 @@ class KeychainManager {
totalDeleted += 1 totalDeleted += 1
} }
SecureLogger.log("Panic mode cleanup completed. Total items deleted: \(totalDeleted)", category: SecureLogger.keychain, level: .warning) SecureLogger.warning("Panic mode cleanup completed. Total items deleted: \(totalDeleted)", category: .keychain)
return totalDeleted > 0 return totalDeleted > 0
} }
@@ -311,7 +317,7 @@ class KeychainManager {
// MARK: - Security Utilities // MARK: - Security Utilities
/// Securely clear sensitive data from memory /// Securely clear sensitive data from memory
static func secureClear(_ data: inout Data) { func secureClear(_ data: inout Data) {
_ = data.withUnsafeMutableBytes { bytes in _ = data.withUnsafeMutableBytes { bytes in
// Use volatile memset to prevent compiler optimization // Use volatile memset to prevent compiler optimization
memset_s(bytes.baseAddress, bytes.count, 0, bytes.count) memset_s(bytes.baseAddress, bytes.count, 0, bytes.count)
@@ -320,7 +326,7 @@ class KeychainManager {
} }
/// Securely clear sensitive string from memory /// Securely clear sensitive string from memory
static func secureClear(_ string: inout String) { func secureClear(_ string: inout String) {
// Convert to mutable data and clear // Convert to mutable data and clear
if var data = string.data(using: .utf8) { if var data = string.data(using: .utf8) {
secureClear(&data) secureClear(&data)
@@ -197,8 +197,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
// Surface as denied/restricted if relevant; otherwise keep previous state // Surface as denied/restricted if relevant; otherwise keep previous state
SecureLogger.log("LocationChannelManager: location error: \(error.localizedDescription)", SecureLogger.error("LocationChannelManager: location error: \(error.localizedDescription)", category: .session)
category: SecureLogger.session, level: .error)
} }
// MARK: - Helpers // MARK: - Helpers
+10 -18
View File
@@ -39,40 +39,34 @@ final class MessageRouter {
func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) { func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
let reachableMesh = mesh.isPeerReachable(peerID) let reachableMesh = mesh.isPeerReachable(peerID)
if reachableMesh { if reachableMesh {
SecureLogger.log("Routing PM via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))", SecureLogger.debug("Routing PM via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))", category: .session)
category: SecureLogger.session, level: .debug)
// BLEService will initiate a handshake if needed and queue the message // BLEService will initiate a handshake if needed and queue the message
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
} else if canSendViaNostr(peerID: peerID) { } else if canSendViaNostr(peerID: peerID) {
SecureLogger.log("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))", SecureLogger.debug("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))", category: .session)
category: SecureLogger.session, level: .debug)
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
} else { } else {
// Queue for later (when mesh connects or Nostr mapping appears) // Queue for later (when mesh connects or Nostr mapping appears)
if outbox[peerID] == nil { outbox[peerID] = [] } if outbox[peerID] == nil { outbox[peerID] = [] }
outbox[peerID]?.append((content, recipientNickname, messageID)) outbox[peerID]?.append((content, recipientNickname, messageID))
SecureLogger.log("Queued PM for \(peerID.prefix(8))… (no mesh, no Nostr mapping) id=\(messageID.prefix(8))", SecureLogger.debug("Queued PM for \(peerID.prefix(8))… (no mesh, no Nostr mapping) id=\(messageID.prefix(8))", category: .session)
category: SecureLogger.session, level: .debug)
} }
} }
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) { func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
// Prefer mesh for reachable peers; BLE will queue if handshake is needed // Prefer mesh for reachable peers; BLE will queue if handshake is needed
if mesh.isPeerReachable(peerID) { if mesh.isPeerReachable(peerID) {
SecureLogger.log("Routing READ ack via mesh (reachable) to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))", SecureLogger.debug("Routing READ ack via mesh (reachable) to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))", category: .session)
category: SecureLogger.session, level: .debug)
mesh.sendReadReceipt(receipt, to: peerID) mesh.sendReadReceipt(receipt, to: peerID)
} else { } else {
SecureLogger.log("Routing READ ack via Nostr to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))", SecureLogger.debug("Routing READ ack via Nostr to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))", category: .session)
category: SecureLogger.session, level: .debug)
nostr.sendReadReceipt(receipt, to: peerID) nostr.sendReadReceipt(receipt, to: peerID)
} }
} }
func sendDeliveryAck(_ messageID: String, to peerID: String) { func sendDeliveryAck(_ messageID: String, to peerID: String) {
if mesh.isPeerReachable(peerID) { if mesh.isPeerReachable(peerID) {
SecureLogger.log("Routing DELIVERED ack via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))", SecureLogger.debug("Routing DELIVERED ack via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))", category: .session)
category: SecureLogger.session, level: .debug)
mesh.sendDeliveryAck(for: messageID, to: peerID) mesh.sendDeliveryAck(for: messageID, to: peerID)
} else { } else {
nostr.sendDeliveryAck(for: messageID, to: peerID) nostr.sendDeliveryAck(for: messageID, to: peerID)
@@ -80,6 +74,7 @@ final class MessageRouter {
} }
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) { func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
// Route via mesh when connected; else use Nostr
if mesh.isPeerConnected(peerID) { if mesh.isPeerConnected(peerID) {
mesh.sendFavoriteNotification(to: peerID, isFavorite: isFavorite) mesh.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
} else { } else {
@@ -108,18 +103,15 @@ final class MessageRouter {
func flushOutbox(for peerID: String) { func flushOutbox(for peerID: String) {
guard let queued = outbox[peerID], !queued.isEmpty else { return } guard let queued = outbox[peerID], !queued.isEmpty else { return }
SecureLogger.log("Flushing outbox for \(peerID.prefix(8))… count=\(queued.count)", SecureLogger.debug("Flushing outbox for \(peerID.prefix(8))… count=\(queued.count)", category: .session)
category: SecureLogger.session, level: .debug)
var remaining: [(content: String, nickname: String, messageID: String)] = [] var remaining: [(content: String, nickname: String, messageID: String)] = []
// Prefer mesh if connected; else try Nostr if mapping exists // Prefer mesh if connected; else try Nostr if mapping exists
for (content, nickname, messageID) in queued { for (content, nickname, messageID) in queued {
if mesh.isPeerReachable(peerID) { if mesh.isPeerReachable(peerID) {
SecureLogger.log("Outbox -> mesh for \(peerID.prefix(8))… id=\(messageID.prefix(8))", SecureLogger.debug("Outbox -> mesh for \(peerID.prefix(8))… id=\(messageID.prefix(8))", category: .session)
category: SecureLogger.session, level: .debug)
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID) mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
} else if canSendViaNostr(peerID: peerID) { } else if canSendViaNostr(peerID: peerID) {
SecureLogger.log("Outbox -> Nostr for \(peerID.prefix(8))… id=\(messageID.prefix(8))", SecureLogger.debug("Outbox -> Nostr for \(peerID.prefix(8))… id=\(messageID.prefix(8))", category: .session)
category: SecureLogger.session, level: .debug)
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID) nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
} else { } else {
// Keep unsent items queued // Keep unsent items queued
+30 -28
View File
@@ -85,7 +85,6 @@
import Foundation import Foundation
import CryptoKit import CryptoKit
import os.log
// MARK: - Encryption Status // MARK: - Encryption Status
@@ -135,7 +134,7 @@ enum EncryptionStatus: Equatable {
/// Provides a high-level API for establishing secure channels between peers, /// Provides a high-level API for establishing secure channels between peers,
/// handling all cryptographic operations transparently. /// handling all cryptographic operations transparently.
/// - Important: This service maintains the device's cryptographic identity /// - Important: This service maintains the device's cryptographic identity
class NoiseEncryptionService { final class NoiseEncryptionService {
// Static identity key (persistent across sessions) // Static identity key (persistent across sessions)
private let staticIdentityKey: Curve25519.KeyAgreement.PrivateKey private let staticIdentityKey: Curve25519.KeyAgreement.PrivateKey
public let staticIdentityPublicKey: Curve25519.KeyAgreement.PublicKey public let staticIdentityPublicKey: Curve25519.KeyAgreement.PublicKey
@@ -156,6 +155,7 @@ class NoiseEncryptionService {
// Security components // Security components
private let rateLimiter = NoiseRateLimiter() private let rateLimiter = NoiseRateLimiter()
private let keychain: KeychainManagerProtocol
// Session maintenance // Session maintenance
private var rekeyTimer: Timer? private var rekeyTimer: Timer?
@@ -182,15 +182,17 @@ class NoiseEncryptionService {
} }
} }
init() { init(keychain: KeychainManagerProtocol) {
self.keychain = keychain
// Load or create static identity key (ONLY from keychain) // Load or create static identity key (ONLY from keychain)
let loadedKey: Curve25519.KeyAgreement.PrivateKey let loadedKey: Curve25519.KeyAgreement.PrivateKey
// Try to load from keychain // Try to load from keychain
if let identityData = KeychainManager.shared.getIdentityKey(forKey: "noiseStaticKey"), if let identityData = keychain.getIdentityKey(forKey: "noiseStaticKey"),
let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: identityData) { let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: identityData) {
loadedKey = key loadedKey = key
SecureLogger.logKeyOperation("load", keyType: "noiseStaticKey", success: true) SecureLogger.logKeyOperation(.load, keyType: "noiseStaticKey", success: true)
} }
// If no identity exists, create new one // If no identity exists, create new one
else { else {
@@ -198,8 +200,8 @@ class NoiseEncryptionService {
let keyData = loadedKey.rawRepresentation let keyData = loadedKey.rawRepresentation
// Save to keychain // Save to keychain
let saved = KeychainManager.shared.saveIdentityKey(keyData, forKey: "noiseStaticKey") let saved = keychain.saveIdentityKey(keyData, forKey: "noiseStaticKey")
SecureLogger.logKeyOperation("create", keyType: "noiseStaticKey", success: saved) SecureLogger.logKeyOperation(.create, keyType: "noiseStaticKey", success: saved)
} }
// Now assign the final value // Now assign the final value
@@ -210,10 +212,10 @@ class NoiseEncryptionService {
let loadedSigningKey: Curve25519.Signing.PrivateKey let loadedSigningKey: Curve25519.Signing.PrivateKey
// Try to load from keychain // Try to load from keychain
if let signingData = KeychainManager.shared.getIdentityKey(forKey: "ed25519SigningKey"), if let signingData = keychain.getIdentityKey(forKey: "ed25519SigningKey"),
let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingData) { let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingData) {
loadedSigningKey = key loadedSigningKey = key
SecureLogger.logKeyOperation("load", keyType: "ed25519SigningKey", success: true) SecureLogger.logKeyOperation(.load, keyType: "ed25519SigningKey", success: true)
} }
// If no signing key exists, create new one // If no signing key exists, create new one
else { else {
@@ -221,8 +223,8 @@ class NoiseEncryptionService {
let keyData = loadedSigningKey.rawRepresentation let keyData = loadedSigningKey.rawRepresentation
// Save to keychain // Save to keychain
let saved = KeychainManager.shared.saveIdentityKey(keyData, forKey: "ed25519SigningKey") let saved = keychain.saveIdentityKey(keyData, forKey: "ed25519SigningKey")
SecureLogger.logKeyOperation("create", keyType: "ed25519SigningKey", success: saved) SecureLogger.logKeyOperation(.create, keyType: "ed25519SigningKey", success: saved)
} }
// Now assign the signing keys // Now assign the signing keys
@@ -230,7 +232,7 @@ class NoiseEncryptionService {
self.signingPublicKey = signingKey.publicKey self.signingPublicKey = signingKey.publicKey
// Initialize session manager // Initialize session manager
self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey) self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain)
// Set up session callbacks // Set up session callbacks
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in
@@ -267,10 +269,10 @@ class NoiseEncryptionService {
/// Clear persistent identity (for panic mode) /// Clear persistent identity (for panic mode)
func clearPersistentIdentity() { func clearPersistentIdentity() {
// Clear from keychain // Clear from keychain
let deletedStatic = KeychainManager.shared.deleteIdentityKey(forKey: "noiseStaticKey") let deletedStatic = keychain.deleteIdentityKey(forKey: "noiseStaticKey")
let deletedSigning = KeychainManager.shared.deleteIdentityKey(forKey: "ed25519SigningKey") let deletedSigning = keychain.deleteIdentityKey(forKey: "ed25519SigningKey")
SecureLogger.logKeyOperation("delete", keyType: "identity keys", success: deletedStatic && deletedSigning) SecureLogger.logKeyOperation(.delete, keyType: "identity keys", success: deletedStatic && deletedSigning)
SecureLogger.log("Panic mode activated - identity cleared", category: SecureLogger.security, level: .warning) SecureLogger.warning("Panic mode activated - identity cleared", category: .security)
// Stop rekey timer // Stop rekey timer
stopRekeyTimer() stopRekeyTimer()
} }
@@ -281,7 +283,7 @@ class NoiseEncryptionService {
let signature = try signingKey.signature(for: data) let signature = try signingKey.signature(for: data)
return signature return signature
} catch { } catch {
SecureLogger.logError(error, context: "Failed to sign data", category: SecureLogger.noise) SecureLogger.error(error, context: "Failed to sign data")
return nil return nil
} }
} }
@@ -292,7 +294,7 @@ class NoiseEncryptionService {
let signingPublicKey = try Curve25519.Signing.PublicKey(rawRepresentation: publicKey) let signingPublicKey = try Curve25519.Signing.PublicKey(rawRepresentation: publicKey)
return signingPublicKey.isValidSignature(signature, for: data) return signingPublicKey.isValidSignature(signature, for: data)
} catch { } catch {
SecureLogger.logError(error, context: "Failed to verify signature", category: SecureLogger.noise) SecureLogger.error(error, context: "Failed to verify signature")
return false return false
} }
} }
@@ -392,17 +394,17 @@ class NoiseEncryptionService {
// Validate peer ID // Validate peer ID
guard NoiseSecurityValidator.validatePeerID(peerID) else { guard NoiseSecurityValidator.validatePeerID(peerID) else {
SecureLogger.logSecurityEvent(.authenticationFailed(peerID: peerID), level: .warning) SecureLogger.warning(.authenticationFailed(peerID: peerID))
throw NoiseSecurityError.invalidPeerID throw NoiseSecurityError.invalidPeerID
} }
// Check rate limit // Check rate limit
guard rateLimiter.allowHandshake(from: peerID) else { guard rateLimiter.allowHandshake(from: peerID) else {
SecureLogger.logSecurityEvent(.authenticationFailed(peerID: "Rate limited: \(peerID)"), level: .warning) SecureLogger.warning(.authenticationFailed(peerID: "Rate limited: \(peerID)"))
throw NoiseSecurityError.rateLimitExceeded throw NoiseSecurityError.rateLimitExceeded
} }
SecureLogger.logSecurityEvent(.handshakeStarted(peerID: peerID)) SecureLogger.info(.handshakeStarted(peerID: peerID))
// Return raw handshake data without wrapper // Return raw handshake data without wrapper
// The Noise protocol handles its own message format // The Noise protocol handles its own message format
@@ -415,19 +417,19 @@ class NoiseEncryptionService {
// Validate peer ID // Validate peer ID
guard NoiseSecurityValidator.validatePeerID(peerID) else { guard NoiseSecurityValidator.validatePeerID(peerID) else {
SecureLogger.logSecurityEvent(.authenticationFailed(peerID: peerID), level: .warning) SecureLogger.warning(.authenticationFailed(peerID: peerID))
throw NoiseSecurityError.invalidPeerID throw NoiseSecurityError.invalidPeerID
} }
// Validate message size // Validate message size
guard NoiseSecurityValidator.validateHandshakeMessageSize(message) else { guard NoiseSecurityValidator.validateHandshakeMessageSize(message) else {
SecureLogger.logSecurityEvent(.handshakeFailed(peerID: peerID, error: "Message too large"), level: .warning) SecureLogger.warning(.handshakeFailed(peerID: peerID, error: "Message too large"))
throw NoiseSecurityError.messageTooLarge throw NoiseSecurityError.messageTooLarge
} }
// Check rate limit // Check rate limit
guard rateLimiter.allowHandshake(from: peerID) else { guard rateLimiter.allowHandshake(from: peerID) else {
SecureLogger.logSecurityEvent(.authenticationFailed(peerID: "Rate limited: \(peerID)"), level: .warning) SecureLogger.warning(.authenticationFailed(peerID: "Rate limited: \(peerID)"))
throw NoiseSecurityError.rateLimitExceeded throw NoiseSecurityError.rateLimitExceeded
} }
@@ -521,7 +523,7 @@ class NoiseEncryptionService {
peerFingerprints.removeValue(forKey: peerID) peerFingerprints.removeValue(forKey: peerID)
} }
SecureLogger.logSecurityEvent(.sessionExpired(peerID: peerID)) SecureLogger.info(.sessionExpired(peerID: peerID))
} }
// MARK: - Private Helpers // MARK: - Private Helpers
@@ -537,7 +539,7 @@ class NoiseEncryptionService {
} }
// Log security event // Log security event
SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID)) SecureLogger.info(.handshakeCompleted(peerID: peerID))
// Notify all handlers about authentication // Notify all handlers about authentication
serviceQueue.async { [weak self] in serviceQueue.async { [weak self] in
@@ -573,12 +575,12 @@ class NoiseEncryptionService {
// Attempt to rekey the session // Attempt to rekey the session
do { do {
try sessionManager.initiateRekey(for: peerID) try sessionManager.initiateRekey(for: peerID)
SecureLogger.log("Key rotation initiated for peer: \(peerID)", category: SecureLogger.security, level: .debug) SecureLogger.debug("Key rotation initiated for peer: \(peerID)", category: .security)
// Signal that handshake is needed // Signal that handshake is needed
onHandshakeRequired?(peerID) onHandshakeRequired?(peerID)
} catch { } catch {
SecureLogger.logError(error, context: "Failed to initiate rekey for peer: \(peerID)", category: SecureLogger.session) SecureLogger.error(error, context: "Failed to initiate rekey for peer: \(peerID)", category: .session)
} }
} }
} }
+39 -40
View File
@@ -21,11 +21,16 @@ final class NostrTransport: Transport {
private var readQueue: [QueuedRead] = [] private var readQueue: [QueuedRead] = []
private var isSendingReadAcks = false private var isSendingReadAcks = false
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
private let keychain: KeychainManagerProtocol
var myPeerID: String { senderPeerID } var myPeerID: String { senderPeerID }
var myNickname: String { "" } var myNickname: String { "" }
func setNickname(_ nickname: String) { /* not used for Nostr */ } func setNickname(_ nickname: String) { /* not used for Nostr */ }
init(keychain: KeychainManagerProtocol) {
self.keychain = keychain
}
func startServices() { /* no-op */ } func startServices() { /* no-op */ }
func stopServices() { /* no-op */ } func stopServices() { /* no-op */ }
func emergencyDisconnectAll() { /* no-op */ } func emergencyDisconnectAll() { /* no-op */ }
@@ -38,11 +43,17 @@ final class NostrTransport: Transport {
func getFingerprint(for peerID: String) -> String? { nil } func getFingerprint(for peerID: String) -> String? { nil }
func getNoiseSessionState(for peerID: String) -> LazyHandshakeState { .none } func getNoiseSessionState(for peerID: String) -> LazyHandshakeState { .none }
func triggerHandshake(with peerID: String) { /* no-op */ } func triggerHandshake(with peerID: String) { /* no-op */ }
// Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation // Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation
private static var cachedNoiseService: NoiseEncryptionService = { private static var cachedNoiseService: NoiseEncryptionService?
NoiseEncryptionService() func getNoiseService() -> NoiseEncryptionService {
}() if let noiseService = Self.cachedNoiseService {
func getNoiseService() -> NoiseEncryptionService { Self.cachedNoiseService } return noiseService
}
let noiseService = NoiseEncryptionService(keychain: keychain)
Self.cachedNoiseService = noiseService
return noiseService
}
// Public broadcast not supported over Nostr here // Public broadcast not supported over Nostr here
func sendMessage(_ content: String, mentions: [String]) { /* no-op */ } func sendMessage(_ content: String, mentions: [String]) { /* no-op */ }
@@ -51,31 +62,29 @@ final class NostrTransport: Transport {
Task { @MainActor in Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return } guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return } guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
SecureLogger.log("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.prefix(8))… id=\(messageID.prefix(8))", SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.prefix(8))… id=\(messageID.prefix(8))", category: .session)
category: SecureLogger.session, level: .debug)
// Convert recipient npub -> hex (x-only) // Convert recipient npub -> hex (x-only)
let recipientHex: String let recipientHex: String
do { do {
let (hrp, data) = try Bech32.decode(recipientNpub) let (hrp, data) = try Bech32.decode(recipientNpub)
guard hrp == "npub" else { guard hrp == "npub" else {
SecureLogger.log("NostrTransport: recipient key not npub (hrp=\(hrp))", category: SecureLogger.session, level: .error) SecureLogger.error("NostrTransport: recipient key not npub (hrp=\(hrp))", category: .session)
return return
} }
recipientHex = data.hexEncodedString() recipientHex = data.hexEncodedString()
} catch { } catch {
SecureLogger.log("NostrTransport: failed to decode npub -> hex: \(error)", category: SecureLogger.session, level: .error) SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
return return
} }
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else { guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
SecureLogger.log("NostrTransport: failed to embed PM packet", category: SecureLogger.session, level: .error) SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
return return
} }
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else { guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.log("NostrTransport: failed to build Nostr event for PM", category: SecureLogger.session, level: .error) SecureLogger.error("NostrTransport: failed to build Nostr event for PM", category: .session)
return return
} }
SecureLogger.log("NostrTransport: sending PM giftWrap id=\(event.id.prefix(16))", SecureLogger.debug("NostrTransport: sending PM giftWrap id=\(event.id.prefix(16))", category: .session)
category: SecureLogger.session, level: .debug)
NostrRelayManager.shared.sendEvent(event) NostrRelayManager.shared.sendEvent(event)
} }
} }
@@ -99,8 +108,7 @@ final class NostrTransport: Transport {
Task { @MainActor in Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return } guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return }
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return } guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
SecureLogger.log("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))", SecureLogger.debug("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))", category: .session)
category: SecureLogger.session, level: .debug)
// Convert recipient npub -> hex // Convert recipient npub -> hex
let recipientHex: String let recipientHex: String
do { do {
@@ -109,15 +117,14 @@ final class NostrTransport: Transport {
recipientHex = data.hexEncodedString() recipientHex = data.hexEncodedString()
} catch { scheduleNextReadAck(); return } } catch { scheduleNextReadAck(); return }
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else { guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else {
SecureLogger.log("NostrTransport: failed to embed READ ack", category: SecureLogger.session, level: .error) SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
scheduleNextReadAck(); return scheduleNextReadAck(); return
} }
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else { guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.log("NostrTransport: failed to build Nostr event for READ ack", category: SecureLogger.session, level: .error) SecureLogger.error("NostrTransport: failed to build Nostr event for READ ack", category: .session)
scheduleNextReadAck(); return scheduleNextReadAck(); return
} }
SecureLogger.log("NostrTransport: sending READ ack giftWrap id=\(event.id.prefix(16))", SecureLogger.debug("NostrTransport: sending READ ack giftWrap id=\(event.id.prefix(16))", category: .session)
category: SecureLogger.session, level: .debug)
NostrRelayManager.shared.sendEvent(event) NostrRelayManager.shared.sendEvent(event)
scheduleNextReadAck() scheduleNextReadAck()
} }
@@ -136,8 +143,7 @@ final class NostrTransport: Transport {
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return } guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return } guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)" let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
SecureLogger.log("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))", SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))", category: .session)
category: SecureLogger.session, level: .debug)
// Convert recipient npub -> hex // Convert recipient npub -> hex
let recipientHex: String let recipientHex: String
do { do {
@@ -146,15 +152,14 @@ final class NostrTransport: Transport {
recipientHex = data.hexEncodedString() recipientHex = data.hexEncodedString()
} catch { return } } catch { return }
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else { guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
SecureLogger.log("NostrTransport: failed to embed favorite notification", category: SecureLogger.session, level: .error) SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
return return
} }
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else { guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.log("NostrTransport: failed to build Nostr event for favorite notification", category: SecureLogger.session, level: .error) SecureLogger.error("NostrTransport: failed to build Nostr event for favorite notification", category: .session)
return return
} }
SecureLogger.log("NostrTransport: sending favorite giftWrap id=\(event.id.prefix(16))", SecureLogger.debug("NostrTransport: sending favorite giftWrap id=\(event.id.prefix(16))", category: .session)
category: SecureLogger.session, level: .debug)
NostrRelayManager.shared.sendEvent(event) NostrRelayManager.shared.sendEvent(event)
} }
} }
@@ -180,8 +185,7 @@ final class NostrTransport: Transport {
Task { @MainActor in Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return } guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return } guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
SecureLogger.log("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))", SecureLogger.debug("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))", category: .session)
category: SecureLogger.session, level: .debug)
let recipientHex: String let recipientHex: String
do { do {
let (hrp, data) = try Bech32.decode(recipientNpub) let (hrp, data) = try Bech32.decode(recipientNpub)
@@ -189,15 +193,14 @@ final class NostrTransport: Transport {
recipientHex = data.hexEncodedString() recipientHex = data.hexEncodedString()
} catch { return } } catch { return }
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else { guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
SecureLogger.log("NostrTransport: failed to embed DELIVERED ack", category: SecureLogger.session, level: .error) SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
return return
} }
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else { guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.log("NostrTransport: failed to build Nostr event for DELIVERED ack", category: SecureLogger.session, level: .error) SecureLogger.error("NostrTransport: failed to build Nostr event for DELIVERED ack", category: .session)
return return
} }
SecureLogger.log("NostrTransport: sending DELIVERED ack giftWrap id=\(event.id.prefix(16))", SecureLogger.debug("NostrTransport: sending DELIVERED ack giftWrap id=\(event.id.prefix(16))", category: .session)
category: SecureLogger.session, level: .debug)
NostrRelayManager.shared.sendEvent(event) NostrRelayManager.shared.sendEvent(event)
} }
} }
@@ -205,8 +208,7 @@ final class NostrTransport: Transport {
// MARK: - Geohash ACK helpers // MARK: - Geohash ACK helpers
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
Task { @MainActor in Task { @MainActor in
SecureLogger.log("GeoDM: send DELIVERED -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))", SecureLogger.debug("GeoDM: send DELIVERED -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))", category: .session)
category: SecureLogger.session, level: .debug)
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return } guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return } guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
NostrRelayManager.registerPendingGiftWrap(id: event.id) NostrRelayManager.registerPendingGiftWrap(id: event.id)
@@ -216,8 +218,7 @@ final class NostrTransport: Transport {
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
Task { @MainActor in Task { @MainActor in
SecureLogger.log("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))", SecureLogger.debug("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))", category: .session)
category: SecureLogger.session, level: .debug)
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return } guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return } guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
NostrRelayManager.registerPendingGiftWrap(id: event.id) NostrRelayManager.registerPendingGiftWrap(id: event.id)
@@ -229,19 +230,17 @@ final class NostrTransport: Transport {
func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) { func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
Task { @MainActor in Task { @MainActor in
guard !recipientHex.isEmpty else { return } guard !recipientHex.isEmpty else { return }
SecureLogger.log("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))", SecureLogger.debug("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))", category: .session)
category: SecureLogger.session, level: .debug)
// Build embedded BitChat packet without recipient peer ID // Build embedded BitChat packet without recipient peer ID
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else { guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else {
SecureLogger.log("NostrTransport: failed to embed geohash PM packet", category: SecureLogger.session, level: .error) SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
return return
} }
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else {
SecureLogger.log("NostrTransport: failed to build Nostr event for geohash PM", category: SecureLogger.session, level: .error) SecureLogger.error("NostrTransport: failed to build Nostr event for geohash PM", category: .session)
return return
} }
SecureLogger.log("NostrTransport: sending geohash PM giftWrap id=\(event.id.prefix(16))", SecureLogger.debug("NostrTransport: sending geohash PM giftWrap id=\(event.id.prefix(16))", category: .session)
category: SecureLogger.session, level: .debug)
NostrRelayManager.registerPendingGiftWrap(id: event.id) NostrRelayManager.registerPendingGiftWrap(id: event.id)
NostrRelayManager.shared.sendEvent(event) NostrRelayManager.shared.sendEvent(event)
} }
+1 -1
View File
@@ -14,7 +14,7 @@ import UIKit
import AppKit import AppKit
#endif #endif
class NotificationService { final class NotificationService {
static let shared = NotificationService() static let shared = NotificationService()
private init() {} private init() {}
+2 -3
View File
@@ -10,7 +10,7 @@ import Foundation
import SwiftUI import SwiftUI
/// Manages all private chat functionality /// Manages all private chat functionality
class PrivateChatManager: ObservableObject { final class PrivateChatManager: ObservableObject {
@Published var privateChats: [String: [BitchatMessage]] = [:] @Published var privateChats: [String: [BitchatMessage]] = [:]
@Published var selectedPeer: String? = nil @Published var selectedPeer: String? = nil
@Published var unreadMessages: Set<String> = [] @Published var unreadMessages: Set<String> = []
@@ -228,8 +228,7 @@ class PrivateChatManager: ObservableObject {
// Route via MessageRouter to avoid handshakeRequired spam when session isn't established // Route via MessageRouter to avoid handshakeRequired spam when session isn't established
if let router = messageRouter { if let router = messageRouter {
SecureLogger.log("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.prefix(8))… via router", SecureLogger.debug("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.prefix(8))… via router", category: .session)
category: SecureLogger.session, level: .debug)
Task { @MainActor in Task { @MainActor in
router.sendReadReceipt(receipt, to: senderPeerID) router.sendReadReceipt(receipt, to: senderPeerID)
} }
+13 -13
View File
@@ -189,7 +189,7 @@ final class TorManager: ObservableObject {
var started = false var started = false
// If already running (per C glue), treat as started // If already running (per C glue), treat as started
if tor_host_is_running() != 0 { if tor_host_is_running() != 0 {
SecureLogger.log("TorManager: embed reports already running", category: SecureLogger.session, level: .info) SecureLogger.info("TorManager: embed reports already running", category: .session)
return true return true
} }
dir.withCString { dptr in dir.withCString { dptr in
@@ -198,9 +198,9 @@ final class TorManager: ObservableObject {
let rc = tor_host_start(dptr, sptr, cptr, 1) let rc = tor_host_start(dptr, sptr, cptr, 1)
started = (rc == 0) started = (rc == 0)
if rc != 0 { if rc != 0 {
SecureLogger.log("TorManager: tor_host_start failed rc=\(rc)", category: SecureLogger.session, level: .error) SecureLogger.error("TorManager: tor_host_start failed rc=\(rc)", category: .session)
} else { } else {
SecureLogger.log("TorManager: tor_host_start OK (\(socks), control \(control))", category: SecureLogger.session, level: .info) SecureLogger.info("TorManager: tor_host_start OK (\(socks), control \(control))", category: .session)
} }
} }
} }
@@ -215,10 +215,10 @@ final class TorManager: ObservableObject {
await MainActor.run { await MainActor.run {
self.socksReady = ready self.socksReady = ready
if ready { if ready {
SecureLogger.log("TorManager: SOCKS ready at \(self.socksHost):\(self.socksPort) [embed]", category: SecureLogger.session, level: .info) SecureLogger.info("TorManager: SOCKS ready at \(self.socksHost):\(self.socksPort) [embed]", category: .session)
} else { } else {
self.lastError = NSError(domain: "TorManager", code: -14, userInfo: [NSLocalizedDescriptionKey: "Tor SOCKS not reachable after embed start"]) self.lastError = NSError(domain: "TorManager", code: -14, userInfo: [NSLocalizedDescriptionKey: "Tor SOCKS not reachable after embed start"])
SecureLogger.log("TorManager: SOCKS not reachable (timeout) [embed]", category: SecureLogger.session, level: .error) SecureLogger.error("TorManager: SOCKS not reachable (timeout) [embed]", category: .session)
} }
} }
} }
@@ -282,13 +282,13 @@ final class TorManager: ObservableObject {
/// Returns true if the attempt started and port probing was scheduled. /// Returns true if the attempt started and port probing was scheduled.
private func startTorViaDlopen() -> Bool { private func startTorViaDlopen() -> Bool {
guard let fwURL = frameworkBinaryURL() else { guard let fwURL = frameworkBinaryURL() else {
SecureLogger.log("TorManager: no embedded tor framework found", category: SecureLogger.session, level: .warning) SecureLogger.warning("TorManager: no embedded tor framework found", category: .session)
return false return false
} }
// Load the library // Load the library
let mode = RTLD_NOW | RTLD_LOCAL let mode = RTLD_NOW | RTLD_LOCAL
SecureLogger.log("TorManager: dlopen(\(fwURL.lastPathComponent))…", category: SecureLogger.session, level: .info) SecureLogger.info("TorManager: dlopen(\(fwURL.lastPathComponent))…", category: .session)
guard let handle = dlopen(fwURL.path, mode) else { guard let handle = dlopen(fwURL.path, mode) else {
let err = String(cString: dlerror()) let err = String(cString: dlerror())
self.lastError = NSError(domain: "TorManager", code: -10, userInfo: [NSLocalizedDescriptionKey: "dlopen failed: \(err)"]) self.lastError = NSError(domain: "TorManager", code: -10, userInfo: [NSLocalizedDescriptionKey: "dlopen failed: \(err)"])
@@ -314,7 +314,7 @@ final class TorManager: ObservableObject {
argv.append(contentsOf: ["-f", torrc]) argv.append(contentsOf: ["-f", torrc])
} }
// Run Tor on a background thread to avoid blocking the main actor // Run Tor on a background thread to avoid blocking the main actor
SecureLogger.log("TorManager: launching tor_main with torrc", category: SecureLogger.session, level: .info) SecureLogger.info("TorManager: launching tor_main with torrc", category: .session)
let argc = Int32(argv.count) let argc = Int32(argv.count)
DispatchQueue.global(qos: .utility).async { DispatchQueue.global(qos: .utility).async {
// Build stable C argv in this thread // Build stable C argv in this thread
@@ -339,9 +339,9 @@ final class TorManager: ObservableObject {
self.socksReady = ready self.socksReady = ready
if !ready { if !ready {
self.lastError = NSError(domain: "TorManager", code: -12, userInfo: [NSLocalizedDescriptionKey: "Tor SOCKS not reachable after dlopen start"]) self.lastError = NSError(domain: "TorManager", code: -12, userInfo: [NSLocalizedDescriptionKey: "Tor SOCKS not reachable after dlopen start"])
SecureLogger.log("TorManager: SOCKS not reachable (timeout)", category: SecureLogger.session, level: .error) SecureLogger.error("TorManager: SOCKS not reachable (timeout)", category: .session)
} else { } else {
SecureLogger.log("TorManager: SOCKS ready at \(self.socksHost):\(self.socksPort)", category: SecureLogger.session, level: .info) SecureLogger.info("TorManager: SOCKS ready at \(self.socksHost):\(self.socksPort)", category: .session)
} }
// isStarting will be cleared when bootstrap reaches 100% // isStarting will be cleared when bootstrap reaches 100%
} }
@@ -385,7 +385,7 @@ final class TorManager: ObservableObject {
var argv: [String] = ["tor"] var argv: [String] = ["tor"]
if let torrc = torrcURL()?.path { argv.append(contentsOf: ["-f", torrc]) } if let torrc = torrcURL()?.path { argv.append(contentsOf: ["-f", torrc]) }
SecureLogger.log("TorManager: starting tor_main (static)", category: SecureLogger.session, level: .info) SecureLogger.info("TorManager: starting tor_main (static)", category: .session)
let argc = Int32(argv.count) let argc = Int32(argv.count)
DispatchQueue.global(qos: .utility).async { DispatchQueue.global(qos: .utility).async {
// Build stable C argv in this thread // Build stable C argv in this thread
@@ -409,10 +409,10 @@ final class TorManager: ObservableObject {
await MainActor.run { await MainActor.run {
self.socksReady = ready self.socksReady = ready
if ready { if ready {
SecureLogger.log("TorManager: SOCKS ready at \(self.socksHost):\(self.socksPort)", category: SecureLogger.session, level: .info) SecureLogger.info("TorManager: SOCKS ready at \(self.socksHost):\(self.socksPort)", category: .session)
} else { } else {
self.lastError = NSError(domain: "TorManager", code: -13, userInfo: [NSLocalizedDescriptionKey: "Tor SOCKS not reachable after static start"]) self.lastError = NSError(domain: "TorManager", code: -13, userInfo: [NSLocalizedDescriptionKey: "Tor SOCKS not reachable after static start"])
SecureLogger.log("TorManager: SOCKS not reachable (timeout)", category: SecureLogger.session, level: .error) SecureLogger.error("TorManager: SOCKS not reachable (timeout)", category: .session)
} }
// isStarting will be cleared when bootstrap reaches 100% // isStarting will be cleared when bootstrap reaches 100%
} }
+2
View File
@@ -37,6 +37,8 @@ enum TransportConfig {
// UI thresholds // UI thresholds
static let uiLateInsertThreshold: TimeInterval = 15.0 static let uiLateInsertThreshold: TimeInterval = 15.0
// Geohash public chats are more sensitive to ordering; use a tighter threshold
static let uiLateInsertThresholdGeo: TimeInterval = 0.0
static let uiProcessedNostrEventsCap: Int = 2000 static let uiProcessedNostrEventsCap: Int = 2000
static let uiChannelInactivityThresholdSeconds: TimeInterval = 9 * 60 static let uiChannelInactivityThresholdSeconds: TimeInterval = 9 * 60
+12 -39
View File
@@ -13,7 +13,7 @@ import CryptoKit
/// Single source of truth for peer state, combining mesh connectivity and favorites /// Single source of truth for peer state, combining mesh connectivity and favorites
@MainActor @MainActor
class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
// MARK: - Published Properties // MARK: - Published Properties
@@ -27,14 +27,16 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
private var peerIndex: [String: BitchatPeer] = [:] private var peerIndex: [String: BitchatPeer] = [:]
private var fingerprintCache: [String: String] = [:] // peerID -> fingerprint private var fingerprintCache: [String: String] = [:] // peerID -> fingerprint
private let meshService: Transport private let meshService: Transport
private let identityManager: SecureIdentityStateManagerProtocol
weak var messageRouter: MessageRouter? weak var messageRouter: MessageRouter?
private let favoritesService = FavoritesPersistenceService.shared private let favoritesService = FavoritesPersistenceService.shared
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
// MARK: - Initialization // MARK: - Initialization
init(meshService: Transport) { init(meshService: Transport, identityManager: SecureIdentityStateManagerProtocol) {
self.meshService = meshService self.meshService = meshService
self.identityManager = identityManager
// Subscribe to changes from both services // Subscribe to changes from both services
setupSubscriptions() setupSubscriptions()
@@ -174,7 +176,7 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
// Determine reachability based on lastSeen and identity trust // Determine reachability based on lastSeen and identity trust
let now = Date() let now = Date()
let fingerprint = peerInfo.noisePublicKey?.sha256Fingerprint() let fingerprint = peerInfo.noisePublicKey?.sha256Fingerprint()
let isVerified = fingerprint.map { SecureIdentityStateManager.shared.isVerified(fingerprint: $0) } ?? false let isVerified = fingerprint.map { identityManager.isVerified(fingerprint: $0) } ?? false
let isFav = peerInfo.noisePublicKey.flatMap { favorites[$0]?.isFavorite } ?? false let isFav = peerInfo.noisePublicKey.flatMap { favorites[$0]?.isFavorite } ?? false
let retention: TimeInterval = (isVerified || isFav) ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds let retention: TimeInterval = (isVerified || isFav) ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds
// A peer is reachable if we recently saw them AND we are attached to the mesh // A peer is reachable if we recently saw them AND we are attached to the mesh
@@ -195,31 +197,6 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
let favoriteStatus = favorites[noiseKey] { let favoriteStatus = favorites[noiseKey] {
peer.favoriteStatus = favoriteStatus peer.favoriteStatus = favoriteStatus
peer.nostrPublicKey = favoriteStatus.peerNostrPublicKey peer.nostrPublicKey = favoriteStatus.peerNostrPublicKey
} else {
// Check by nickname for reconnected peers
let favoriteByNickname = favorites.values.first {
$0.peerNickname == peerInfo.nickname
}
if let favorite = favoriteByNickname,
let noiseKey = peerInfo.noisePublicKey {
SecureLogger.log(
"🔄 Found favorite for '\(peerInfo.nickname)' by nickname, updating noise key",
category: SecureLogger.session,
level: .debug
)
// Update the favorite's key in persistence
favoritesService.updateNoisePublicKey(
from: favorite.peerNoisePublicKey,
to: noiseKey,
peerNickname: peerInfo.nickname
)
// Get updated favorite
peer.favoriteStatus = favoritesService.getFavoriteStatus(for: noiseKey)
peer.nostrPublicKey = peer.favoriteStatus?.peerNostrPublicKey ?? favorite.peerNostrPublicKey
}
} }
return peer return peer
@@ -272,7 +249,7 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
guard let fingerprint = getFingerprint(for: peerID) else { return false } guard let fingerprint = getFingerprint(for: peerID) else { return false }
// Check SecureIdentityStateManager for block status // Check SecureIdentityStateManager for block status
if let identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprint) { if let identity = identityManager.getSocialIdentity(for: fingerprint) {
return identity.isBlocked return identity.isBlocked
} }
@@ -282,8 +259,7 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
/// Toggle favorite status /// Toggle favorite status
func toggleFavorite(_ peerID: String) { func toggleFavorite(_ peerID: String) {
guard let peer = getPeer(by: peerID) else { guard let peer = getPeer(by: peerID) else {
SecureLogger.log("⚠️ Cannot toggle favorite - peer not found: \(peerID)", SecureLogger.warning("⚠️ Cannot toggle favorite - peer not found: \(peerID)", category: .session)
category: SecureLogger.session, level: .warning)
return return
} }
@@ -293,15 +269,13 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
var actualNickname = peer.nickname var actualNickname = peer.nickname
// Debug logging to understand the issue // Debug logging to understand the issue
SecureLogger.log("🔍 Toggle favorite - peer.nickname: '\(peer.nickname)', peer.displayName: '\(peer.displayName)', peerID: \(peerID)", SecureLogger.debug("🔍 Toggle favorite - peer.nickname: '\(peer.nickname)', peer.displayName: '\(peer.displayName)', peerID: \(peerID)", category: .session)
category: SecureLogger.session, level: .debug)
if actualNickname.isEmpty { if actualNickname.isEmpty {
// Try to get from mesh service's current peer list // Try to get from mesh service's current peer list
if let meshPeerNickname = meshService.peerNickname(peerID: peerID) { if let meshPeerNickname = meshService.peerNickname(peerID: peerID) {
actualNickname = meshPeerNickname actualNickname = meshPeerNickname
SecureLogger.log("🔍 Got nickname from mesh service: '\(actualNickname)'", SecureLogger.debug("🔍 Got nickname from mesh service: '\(actualNickname)'", category: .session)
category: SecureLogger.session, level: .debug)
} }
} }
@@ -328,8 +302,7 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
} }
// Log the final nickname being saved // Log the final nickname being saved
SecureLogger.log("⭐️ Toggled favorite for '\(finalNickname)' (peerID: \(peerID), was: \(wasFavorite), now: \(!wasFavorite))", SecureLogger.debug("⭐️ Toggled favorite for '\(finalNickname)' (peerID: \(peerID), was: \(wasFavorite), now: \(!wasFavorite))", category: .session)
category: SecureLogger.session, level: .debug)
// Send favorite notification to the peer via router (mesh or Nostr) // Send favorite notification to the peer via router (mesh or Nostr)
if let router = messageRouter { if let router = messageRouter {
@@ -353,7 +326,7 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
guard let fingerprint = getFingerprint(for: peerID) else { return } guard let fingerprint = getFingerprint(for: peerID) else { return }
// Get or create social identity // Get or create social identity
var identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprint) var identity = identityManager.getSocialIdentity(for: fingerprint)
?? SocialIdentity( ?? SocialIdentity(
fingerprint: fingerprint, fingerprint: fingerprint,
localPetname: nil, localPetname: nil,
@@ -376,7 +349,7 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
} }
} }
SecureIdentityStateManager.shared.updateSocialIdentity(identity) identityManager.updateSocialIdentity(identity)
} }
/// Get fingerprint for peer ID /// Get fingerprint for peer ID
+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")
}
+93 -72
View File
@@ -11,18 +11,7 @@ import os.log
/// Centralized security-aware logging framework /// Centralized security-aware logging framework
/// Provides safe logging that filters sensitive data and security events /// Provides safe logging that filters sensitive data and security events
class SecureLogger { final class SecureLogger {
// MARK: - Log Categories
private static let subsystem = "chat.bitchat"
static let noise = OSLog(subsystem: subsystem, category: "noise")
static let encryption = OSLog(subsystem: subsystem, category: "encryption")
static let keychain = OSLog(subsystem: subsystem, category: "keychain")
static let session = OSLog(subsystem: subsystem, category: "session")
static let security = OSLog(subsystem: subsystem, category: "security")
static let handshake = OSLog(subsystem: subsystem, category: "handshake")
// MARK: - Timestamp Formatter // MARK: - Timestamp Formatter
@@ -124,24 +113,90 @@ class SecureLogger {
// MARK: - Public Logging Methods // MARK: - Public Logging Methods
/// Log a security event static func debug(_ message: @autoclosure () -> String, category: OSLog = .noise,
static func logSecurityEvent(_ event: SecurityEvent, level: LogLevel = .info,
file: String = #file, line: Int = #line, function: String = #function) { file: String = #file, line: Int = #line, function: String = #function) {
guard shouldLog(level) else { return } log(message(), category: category, level: .debug, file: file, line: line, function: function)
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
} }
/// Log general messages with automatic sensitive data filtering static func info(_ message: @autoclosure () -> String, category: OSLog = .noise,
static func log(_ message: @autoclosure () -> String, category: OSLog = noise, level: LogLevel = .debug,
file: String = #file, line: Int = #line, function: String = #function) { 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, level: LogLevel,
file: String, line: Int, function: String) {
guard shouldLog(level) else { return } guard shouldLog(level) else { return }
let location = formatLocation(file: file, line: line, function: function) let location = formatLocation(file: file, line: line, function: function)
let sanitized = sanitize("\(location) \(message())") let sanitized = sanitize("\(location) \(message())")
@@ -156,31 +211,30 @@ class SecureLogger {
#endif #endif
} }
/// Log errors with context /// Log a security event
static func logError(_ error: Error, context: @autoclosure () -> String, category: OSLog = noise, static func logSecurityEvent(_ event: SecurityEvent, level: LogLevel = .info,
file: String = #file, line: Int = #line, function: String = #function) { file: String, line: Int, function: String) {
guard shouldLog(level) else { return }
let location = formatLocation(file: file, line: line, function: function) let location = formatLocation(file: file, line: line, function: function)
let sanitized = sanitize(context()) let message = "\(location) \(event.message)"
let errorDesc = sanitize(error.localizedDescription)
#if DEBUG #if DEBUG
os_log("%{public}@ Error in %{public}@: %{public}@", log: category, type: .error, location, sanitized, errorDesc) os_log("%{public}@", log: .security, type: level.osLogType, message)
#else #else
os_log("%{private}@ Error in %{private}@: %{private}@", log: category, type: .error, location, sanitized, errorDesc) // In release, use private logging to prevent sensitive data exposure
os_log("%{private}@", log: .security, type: level.osLogType, message)
#endif #endif
} }
// MARK: - Private Helpers
/// Format location information for logging /// Format location information for logging
private static func formatLocation(file: String, line: Int, function: String) -> String { static func formatLocation(file: String, line: Int, function: String) -> String {
let fileName = (file as NSString).lastPathComponent let fileName = (file as NSString).lastPathComponent
let timestamp = timestampFormatter.string(from: Date()) let timestamp = timestampFormatter.string(from: Date())
return "[\(timestamp)] [\(fileName):\(line) \(function)]" return "[\(timestamp)] [\(fileName):\(line) \(function)]"
} }
/// Sanitize strings to remove potentially sensitive data /// Sanitize strings to remove potentially sensitive data
private static func sanitize(_ input: String) -> String { static func sanitize(_ input: String) -> String {
let key = input as NSString let key = input as NSString
// Check cache first // Check cache first
@@ -226,45 +280,12 @@ class SecureLogger {
} }
/// Sanitize individual values /// Sanitize individual values
private static func sanitize<T>(_ value: T) -> String { static func sanitize<T>(_ value: T) -> String {
let stringValue = String(describing: value) let stringValue = String(describing: value)
return sanitize(stringValue) return sanitize(stringValue)
} }
} }
// MARK: - Convenience Extensions
extension SecureLogger {
/// Log handshake events
static func logHandshake(_ phase: String, peerID: String, success: Bool = true,
file: String = #file, line: Int = #line, function: String = #function) {
if success {
log("Handshake \(phase) with peer: \(peerID)", category: session, level: .info,
file: file, line: line, function: function)
} else {
log("Handshake \(phase) failed with peer: \(peerID)", category: session, level: .warning,
file: file, line: line, function: function)
}
}
/// Log encryption operations
static func logEncryption(_ operation: String, success: Bool = true,
file: String = #file, line: Int = #line, function: String = #function) {
let level: LogLevel = success ? .debug : .error
log("Encryption operation '\(operation)' \(success ? "succeeded" : "failed")",
category: encryption, level: level, file: file, line: line, function: function)
}
/// Log key management operations
static func logKeyOperation(_ operation: String, keyType: String, success: Bool = true,
file: String = #file, line: Int = #line, function: String = #function) {
let level: LogLevel = success ? .debug : .error
log("Key operation '\(operation)' for \(keyType) \(success ? "succeeded" : "failed")",
category: keychain, level: level, file: file, line: line, function: function)
}
}
// MARK: - Migration Helper // MARK: - Migration Helper
/// Helper to migrate from print statements to SecureLogger /// Helper to migrate from print statements to SecureLogger
@@ -273,6 +294,6 @@ func secureLog(_ items: Any..., separator: String = " ", terminator: String = "\
file: String = #file, line: Int = #line, function: String = #function) { file: String = #file, line: Int = #line, function: String = #function) {
#if DEBUG #if DEBUG
let message = items.map { String(describing: $0) }.joined(separator: separator) let message = items.map { String(describing: $0) }.joined(separator: separator)
SecureLogger.log(message, level: .debug, file: file, line: line, function: function) SecureLogger.debug(message, file: file, line: line, function: function)
#endif #endif
} }
File diff suppressed because it is too large Load Diff
+16 -4
View File
@@ -444,7 +444,19 @@ struct ContentView: View {
let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let peerID = id.removingPercentEncoding ?? id let peerID = id.removingPercentEncoding ?? id
selectedMessageSenderID = peerID selectedMessageSenderID = peerID
selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID })?.sender // Derive a stable display name from the peerID instead of peeking at the last message,
// which may be a transformed system action (sender == "system").
if peerID.hasPrefix("nostr") {
// For geohash senders, resolve display name via mapping (works for "nostr:" and "nostr_" keys)
selectedMessageSender = viewModel.geohashDisplayName(for: peerID)
} else {
// Mesh sender: use current mesh nickname if available; otherwise fall back to last non-system message
if let name = viewModel.meshService.peerNickname(peerID: peerID) {
selectedMessageSender = name
} else {
selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender
}
}
showMessageActions = true showMessageActions = true
} }
.onOpenURL { url in .onOpenURL { url in
@@ -1235,15 +1247,15 @@ struct ContentView: View {
!fav.peerNickname.isEmpty { return fav.peerNickname } !fav.peerNickname.isEmpty { return fav.peerNickname }
// Fallback: resolve from persisted social identity via fingerprint mapping // Fallback: resolve from persisted social identity via fingerprint mapping
if headerPeerID.count == 16 { if headerPeerID.count == 16 {
let candidates = SecureIdentityStateManager.shared.getCryptoIdentitiesByPeerIDPrefix(headerPeerID) let candidates = viewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID)
if let id = candidates.first, if let id = candidates.first,
let social = SecureIdentityStateManager.shared.getSocialIdentity(for: id.fingerprint) { let social = viewModel.identityManager.getSocialIdentity(for: id.fingerprint) {
if let pet = social.localPetname, !pet.isEmpty { return pet } if let pet = social.localPetname, !pet.isEmpty { return pet }
if !social.claimedNickname.isEmpty { return social.claimedNickname } if !social.claimedNickname.isEmpty { return social.claimedNickname }
} }
} else if headerPeerID.count == 64, let keyData = Data(hexString: headerPeerID) { } else if headerPeerID.count == 64, let keyData = Data(hexString: headerPeerID) {
let fp = keyData.sha256Fingerprint() let fp = keyData.sha256Fingerprint()
if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: fp) { if let social = viewModel.identityManager.getSocialIdentity(for: fp) {
if let pet = social.localPetname, !pet.isEmpty { return pet } if let pet = social.localPetname, !pet.isEmpty { return pet }
if !social.claimedNickname.isEmpty { return social.claimedNickname } if !social.claimedNickname.isEmpty { return social.claimedNickname }
} }
+1 -1
View File
@@ -53,7 +53,7 @@ struct FingerprintView: View {
if peerID.count == 64, let data = Data(hexString: peerID) { if peerID.count == 64, let data = Data(hexString: peerID) {
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname } if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname }
let fp = data.sha256Fingerprint() let fp = data.sha256Fingerprint()
if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: fp) { if let social = viewModel.identityManager.getSocialIdentity(for: fp) {
if let pet = social.localPetname, !pet.isEmpty { return pet } if let pet = social.localPetname, !pet.isEmpty { return pet }
if !social.claimedNickname.isEmpty { return social.claimedNickname } if !social.claimedNickname.isEmpty { return social.claimedNickname }
} }
+1 -1
View File
@@ -270,7 +270,7 @@ final class BLEServiceTests: XCTestCase {
// MARK: - Mock Delegate Helper // MARK: - Mock Delegate Helper
private class MockBitchatDelegate: BitchatDelegate { private final class MockBitchatDelegate: BitchatDelegate {
private let messageHandler: (BitchatMessage) -> Void private let messageHandler: (BitchatMessage) -> Void
init(_ handler: @escaping (BitchatMessage) -> Void) { init(_ handler: @escaping (BitchatMessage) -> Void) {
+54
View File
@@ -0,0 +1,54 @@
import XCTest
@testable import bitchat
final class CommandProcessorTests: XCTestCase {
var identityManager: MockIdentityManager!
override func setUp() {
super.setUp()
// Provide a minimal identity manager for commands that query identity/block lists
identityManager = MockIdentityManager(MockKeychain())
}
override func tearDown() {
identityManager = nil
super.tearDown()
}
@MainActor
func test_slap_notFoundGrammar() {
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
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, identityManager: identityManager)
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, identityManager: identityManager)
let result = processor.process("/slap")
switch result {
case .error(let message):
XCTAssertEqual(message, "usage: /slap <nickname>")
default:
XCTFail("Expected error result for usage message")
}
}
}
@@ -16,6 +16,8 @@ final class PrivateChatE2ETests: XCTestCase {
var bob: MockBluetoothMeshService! var bob: MockBluetoothMeshService!
var charlie: MockBluetoothMeshService! var charlie: MockBluetoothMeshService!
private var mockKeychain: MockKeychain!
override func setUp() { override func setUp() {
super.setUp() super.setUp()
MockBLEService.resetTestBus() MockBLEService.resetTestBus()
@@ -24,6 +26,7 @@ final class PrivateChatE2ETests: XCTestCase {
alice = createMockService(peerID: TestConstants.testPeerID1, nickname: TestConstants.testNickname1) alice = createMockService(peerID: TestConstants.testPeerID1, nickname: TestConstants.testNickname1)
bob = createMockService(peerID: TestConstants.testPeerID2, nickname: TestConstants.testNickname2) bob = createMockService(peerID: TestConstants.testPeerID2, nickname: TestConstants.testNickname2)
charlie = createMockService(peerID: TestConstants.testPeerID3, nickname: TestConstants.testNickname3) charlie = createMockService(peerID: TestConstants.testPeerID3, nickname: TestConstants.testNickname3)
mockKeychain = MockKeychain()
// Delivery tracking is now handled internally by BLEService // Delivery tracking is now handled internally by BLEService
} }
@@ -32,6 +35,7 @@ final class PrivateChatE2ETests: XCTestCase {
alice = nil alice = nil
bob = nil bob = nil
charlie = nil charlie = nil
mockKeychain = nil
super.tearDown() super.tearDown()
} }
@@ -116,8 +120,8 @@ final class PrivateChatE2ETests: XCTestCase {
let aliceKey = Curve25519.KeyAgreement.PrivateKey() let aliceKey = Curve25519.KeyAgreement.PrivateKey()
let bobKey = Curve25519.KeyAgreement.PrivateKey() let bobKey = Curve25519.KeyAgreement.PrivateKey()
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey) let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
// Establish encrypted session // Establish encrypted session
do { do {
@@ -11,6 +11,21 @@ import XCTest
final class FragmentationTests: XCTestCase { final class FragmentationTests: XCTestCase {
private var mockKeychain: MockKeychain!
private var mockIdentityManager: MockIdentityManager!
override func setUp() {
super.setUp()
mockKeychain = MockKeychain()
mockIdentityManager = MockIdentityManager(mockKeychain)
}
override func tearDown() {
mockKeychain = nil
mockIdentityManager = nil
super.tearDown()
}
private final class CaptureDelegate: BitchatDelegate { private final class CaptureDelegate: BitchatDelegate {
var publicMessages: [(peerID: String, nickname: String, content: String)] = [] var publicMessages: [(peerID: String, nickname: String, content: String)] = []
func didReceiveMessage(_ message: BitchatMessage) {} func didReceiveMessage(_ message: BitchatMessage) {}
@@ -75,7 +90,7 @@ final class FragmentationTests: XCTestCase {
} }
func test_reassembly_from_fragments_delivers_public_message() { func test_reassembly_from_fragments_delivers_public_message() {
let ble = BLEService() let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager)
let capture = CaptureDelegate() let capture = CaptureDelegate()
ble.delegate = capture ble.delegate = capture
@@ -106,7 +121,7 @@ final class FragmentationTests: XCTestCase {
} }
func test_duplicate_fragment_does_not_break_reassembly() { func test_duplicate_fragment_does_not_break_reassembly() {
let ble = BLEService() let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager)
let capture = CaptureDelegate() let capture = CaptureDelegate()
ble.delegate = capture ble.delegate = capture
@@ -132,7 +147,7 @@ final class FragmentationTests: XCTestCase {
} }
func test_invalid_fragment_header_is_ignored() { func test_invalid_fragment_header_is_ignored() {
let ble = BLEService() let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager)
let capture = CaptureDelegate() let capture = CaptureDelegate()
ble.delegate = capture ble.delegate = capture
@@ -14,6 +14,7 @@ final class IntegrationTests: XCTestCase {
var nodes: [String: MockBluetoothMeshService] = [:] var nodes: [String: MockBluetoothMeshService] = [:]
var noiseManagers: [String: NoiseSessionManager] = [:] var noiseManagers: [String: NoiseSessionManager] = [:]
private var mockKeychain: MockKeychain!
override func setUp() { override func setUp() {
super.setUp() super.setUp()
@@ -21,6 +22,7 @@ final class IntegrationTests: XCTestCase {
// broadcast propagation across a larger mesh. Integration-only. // broadcast propagation across a larger mesh. Integration-only.
MockBLEService.resetTestBus() MockBLEService.resetTestBus()
MockBLEService.autoFloodEnabled = true MockBLEService.autoFloodEnabled = true
mockKeychain = MockKeychain()
// Create a network of nodes // Create a network of nodes
createNode("Alice", peerID: TestConstants.testPeerID1) createNode("Alice", peerID: TestConstants.testPeerID1)
@@ -34,6 +36,7 @@ final class IntegrationTests: XCTestCase {
MockBLEService.autoFloodEnabled = false MockBLEService.autoFloodEnabled = false
nodes.removeAll() nodes.removeAll()
noiseManagers.removeAll() noiseManagers.removeAll()
mockKeychain = nil
super.tearDown() super.tearDown()
} }
@@ -307,7 +310,7 @@ final class IntegrationTests: XCTestCase {
// Simulate Bob restart by recreating his Noise manager // Simulate Bob restart by recreating his Noise manager
let bobKey = Curve25519.KeyAgreement.PrivateKey() let bobKey = Curve25519.KeyAgreement.PrivateKey()
noiseManagers["Bob"] = NoiseSessionManager(localStaticKey: bobKey) noiseManagers["Bob"] = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
// Re-establish Noise handshake explicitly via managers // Re-establish Noise handshake explicitly via managers
do { do {
@@ -593,7 +596,7 @@ final class IntegrationTests: XCTestCase {
// Create Noise manager // Create Noise manager
let key = Curve25519.KeyAgreement.PrivateKey() let key = Curve25519.KeyAgreement.PrivateKey()
noiseManagers[name] = NoiseSessionManager(localStaticKey: key) noiseManagers[name] = NoiseSessionManager(localStaticKey: key, keychain: mockKeychain)
} }
private func connect(_ node1: String, _ node2: String) { private func connect(_ node1: String, _ node2: String) {
+4 -2
View File
@@ -26,7 +26,7 @@ import CoreBluetooth
/// - `autoFloodEnabled` is disabled by default; Integration tests enable it in `setUp()` to /// - `autoFloodEnabled` is disabled by default; Integration tests enable it in `setUp()` to
/// simulate broadcast propagation across the mesh. E2E tests keep it off and perform explicit /// simulate broadcast propagation across the mesh. E2E tests keep it off and perform explicit
/// relays when needed. /// relays when needed.
class MockBLEService: NSObject { final class MockBLEService: NSObject {
// Enable automatic flooding for public messages in integration tests only // Enable automatic flooding for public messages in integration tests only
static var autoFloodEnabled: Bool = false static var autoFloodEnabled: Bool = false
@@ -36,6 +36,8 @@ class MockBLEService: NSObject {
var myPeerID: String = "MOCK1234" var myPeerID: String = "MOCK1234"
var myNickname: String = "MockUser" var myNickname: String = "MockUser"
private let mockKeychain = MockKeychain()
// Test-specific properties // Test-specific properties
var sentMessages: [(message: BitchatMessage, packet: BitchatPacket)] = [] var sentMessages: [(message: BitchatMessage, packet: BitchatPacket)] = []
var sentPackets: [BitchatPacket] = [] var sentPackets: [BitchatPacket] = []
@@ -272,7 +274,7 @@ class MockBLEService: NSObject {
} }
func getNoiseService() -> NoiseEncryptionService { func getNoiseService() -> NoiseEncryptionService {
return NoiseEncryptionService() return NoiseEncryptionService(keychain: mockKeychain)
} }
func getFingerprint(for peerID: String) -> String? { func getFingerprint(for peerID: String) -> String? {
@@ -0,0 +1,80 @@
//
// MockIdentityManager.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
@testable import bitchat
final class MockIdentityManager: SecureIdentityStateManagerProtocol {
private let keychain: KeychainManagerProtocol
init(_ keychain: KeychainManagerProtocol) {
self.keychain = keychain
}
func loadIdentityCache() {}
func saveIdentityCache() {}
func forceSave() {}
func getSocialIdentity(for fingerprint: String) -> SocialIdentity? {
nil
}
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?) {}
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] {
[]
}
func updateSocialIdentity(_ identity: SocialIdentity) {}
func getFavorites() -> Set<String> {
Set()
}
func setFavorite(_ fingerprint: String, isFavorite: Bool) {}
func isFavorite(fingerprint: String) -> Bool {
false
}
func isBlocked(fingerprint: String) -> Bool {
false
}
func setBlocked(_ fingerprint: String, isBlocked: Bool) {}
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool {
true
}
func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) {}
func getBlockedNostrPubkeys() -> Set<String> {
Set()
}
func registerEphemeralSession(peerID: String, handshakeState: HandshakeState) {}
func updateHandshakeState(peerID: String, state: HandshakeState) {}
func clearAllIdentityData() {}
func removeEphemeralSession(peerID: String) {}
func setVerified(fingerprint: String, verified: Bool) {}
func isVerified(fingerprint: String) -> Bool {
true
}
func getVerifiedFingerprints() -> Set<String> {
Set()
}
}
+46
View File
@@ -0,0 +1,46 @@
//
// MockKeychain.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
@testable import bitchat
final class MockKeychain: KeychainManagerProtocol {
private var storage: [String: Data] = [:]
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
storage[key] = keyData
return true
}
func getIdentityKey(forKey key: String) -> Data? {
storage[key]
}
func deleteIdentityKey(forKey key: String) -> Bool {
storage.removeValue(forKey: key)
return true
}
func deleteAllKeychainData() -> Bool {
storage.removeAll()
return true
}
func secureClear(_ data: inout Data) {
//
data = Data()
}
func secureClear(_ string: inout String) {
string = ""
}
func verifyIdentityKeyExists() -> Bool {
storage["identity_noiseStaticKey"] != nil
}
}
+36 -27
View File
@@ -16,16 +16,19 @@ final class NoiseProtocolTests: XCTestCase {
var bobKey: Curve25519.KeyAgreement.PrivateKey! var bobKey: Curve25519.KeyAgreement.PrivateKey!
var aliceSession: NoiseSession! var aliceSession: NoiseSession!
var bobSession: NoiseSession! var bobSession: NoiseSession!
private var mockKeychain: MockKeychain!
override func setUp() { override func setUp() {
super.setUp() super.setUp()
aliceKey = Curve25519.KeyAgreement.PrivateKey() aliceKey = Curve25519.KeyAgreement.PrivateKey()
bobKey = Curve25519.KeyAgreement.PrivateKey() bobKey = Curve25519.KeyAgreement.PrivateKey()
mockKeychain = MockKeychain()
} }
override func tearDown() { override func tearDown() {
aliceSession = nil aliceSession = nil
bobSession = nil bobSession = nil
mockKeychain = nil
super.tearDown() super.tearDown()
} }
@@ -36,12 +39,14 @@ final class NoiseProtocolTests: XCTestCase {
aliceSession = NoiseSession( aliceSession = NoiseSession(
peerID: TestConstants.testPeerID2, peerID: TestConstants.testPeerID2,
role: .initiator, role: .initiator,
keychain: mockKeychain,
localStaticKey: aliceKey localStaticKey: aliceKey
) )
bobSession = NoiseSession( bobSession = NoiseSession(
peerID: TestConstants.testPeerID1, peerID: TestConstants.testPeerID1,
role: .responder, role: .responder,
keychain: mockKeychain,
localStaticKey: bobKey localStaticKey: bobKey
) )
@@ -80,6 +85,7 @@ final class NoiseProtocolTests: XCTestCase {
aliceSession = NoiseSession( aliceSession = NoiseSession(
peerID: TestConstants.testPeerID2, peerID: TestConstants.testPeerID2,
role: .initiator, role: .initiator,
keychain: mockKeychain,
localStaticKey: aliceKey localStaticKey: aliceKey
) )
@@ -144,6 +150,7 @@ final class NoiseProtocolTests: XCTestCase {
aliceSession = NoiseSession( aliceSession = NoiseSession(
peerID: TestConstants.testPeerID2, peerID: TestConstants.testPeerID2,
role: .initiator, role: .initiator,
keychain: mockKeychain,
localStaticKey: aliceKey localStaticKey: aliceKey
) )
@@ -157,7 +164,7 @@ final class NoiseProtocolTests: XCTestCase {
// MARK: - Session Manager Tests // MARK: - Session Manager Tests
func testSessionManagerBasicOperations() throws { func testSessionManagerBasicOperations() throws {
let manager = NoiseSessionManager(localStaticKey: aliceKey) let manager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
// Create session // Create session
let session = manager.createSession(for: TestConstants.testPeerID2, role: .initiator) let session = manager.createSession(for: TestConstants.testPeerID2, role: .initiator)
@@ -174,7 +181,7 @@ final class NoiseProtocolTests: XCTestCase {
} }
func testSessionManagerHandshakeInitiation() throws { func testSessionManagerHandshakeInitiation() throws {
let manager = NoiseSessionManager(localStaticKey: aliceKey) let manager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
// Initiate handshake // Initiate handshake
let handshakeData = try manager.initiateHandshake(with: TestConstants.testPeerID2) let handshakeData = try manager.initiateHandshake(with: TestConstants.testPeerID2)
@@ -187,8 +194,8 @@ final class NoiseProtocolTests: XCTestCase {
} }
func testSessionManagerIncomingHandshake() throws { func testSessionManagerIncomingHandshake() throws {
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey) let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
// Alice initiates // Alice initiates
let message1 = try aliceManager.initiateHandshake(with: TestConstants.testPeerID2) let message1 = try aliceManager.initiateHandshake(with: TestConstants.testPeerID2)
@@ -211,8 +218,8 @@ final class NoiseProtocolTests: XCTestCase {
} }
func testSessionManagerEncryptionDecryption() throws { func testSessionManagerEncryptionDecryption() throws {
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey) let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
// Establish sessions // Establish sessions
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
@@ -256,11 +263,11 @@ final class NoiseProtocolTests: XCTestCase {
func testSessionIsolation() throws { func testSessionIsolation() throws {
// Create two separate session pairs // Create two separate session pairs
let aliceSession1 = NoiseSession(peerID: "peer1", role: .initiator, localStaticKey: aliceKey) let aliceSession1 = NoiseSession(peerID: "peer1", role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey)
let bobSession1 = NoiseSession(peerID: "alice1", role: .responder, localStaticKey: bobKey) let bobSession1 = NoiseSession(peerID: "alice1", role: .responder, keychain: mockKeychain, localStaticKey: bobKey)
let aliceSession2 = NoiseSession(peerID: "peer2", role: .initiator, localStaticKey: aliceKey) let aliceSession2 = NoiseSession(peerID: "peer2", role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey)
let bobSession2 = NoiseSession(peerID: "alice2", role: .responder, localStaticKey: bobKey) let bobSession2 = NoiseSession(peerID: "alice2", role: .responder, keychain: mockKeychain, localStaticKey: bobKey)
// Establish both pairs // Establish both pairs
try performHandshake(initiator: aliceSession1, responder: bobSession1) try performHandshake(initiator: aliceSession1, responder: bobSession1)
@@ -282,8 +289,8 @@ final class NoiseProtocolTests: XCTestCase {
func testPeerRestartDetection() throws { func testPeerRestartDetection() throws {
// Establish initial sessions // Establish initial sessions
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey) let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
@@ -295,7 +302,7 @@ final class NoiseProtocolTests: XCTestCase {
_ = try aliceManager.decrypt(message2, from: TestConstants.testPeerID2) _ = try aliceManager.decrypt(message2, from: TestConstants.testPeerID2)
// Simulate Bob restart by creating new manager with same key // Simulate Bob restart by creating new manager with same key
let bobManagerRestarted = NoiseSessionManager(localStaticKey: bobKey) let bobManagerRestarted = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
// Bob initiates new handshake after restart // Bob initiates new handshake after restart
let newHandshake1 = try bobManagerRestarted.initiateHandshake(with: TestConstants.testPeerID1) let newHandshake1 = try bobManagerRestarted.initiateHandshake(with: TestConstants.testPeerID1)
@@ -318,8 +325,8 @@ final class NoiseProtocolTests: XCTestCase {
func testNonceDesynchronizationRecovery() throws { func testNonceDesynchronizationRecovery() throws {
// Create two sessions // Create two sessions
aliceSession = NoiseSession(peerID: TestConstants.testPeerID2, role: .initiator, localStaticKey: aliceKey) aliceSession = NoiseSession(peerID: TestConstants.testPeerID2, role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey)
bobSession = NoiseSession(peerID: TestConstants.testPeerID1, role: .responder, localStaticKey: bobKey) bobSession = NoiseSession(peerID: TestConstants.testPeerID1, role: .responder, keychain: mockKeychain, localStaticKey: bobKey)
// Establish sessions // Establish sessions
try performHandshake(initiator: aliceSession, responder: bobSession) try performHandshake(initiator: aliceSession, responder: bobSession)
@@ -342,8 +349,8 @@ final class NoiseProtocolTests: XCTestCase {
func testConcurrentEncryption() throws { func testConcurrentEncryption() throws {
// Test thread safety of encryption operations // Test thread safety of encryption operations
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey) let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
@@ -380,8 +387,8 @@ final class NoiseProtocolTests: XCTestCase {
func testSessionStaleDetection() throws { func testSessionStaleDetection() throws {
// Test that sessions are properly marked as stale // Test that sessions are properly marked as stale
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey) let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
@@ -394,8 +401,8 @@ final class NoiseProtocolTests: XCTestCase {
func testHandshakeAfterDecryptionFailure() throws { func testHandshakeAfterDecryptionFailure() throws {
// Test that handshake is properly initiated after decryption failure // Test that handshake is properly initiated after decryption failure
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey) let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
// Establish sessions // Establish sessions
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
@@ -413,8 +420,8 @@ final class NoiseProtocolTests: XCTestCase {
func testHandshakeAlwaysAcceptedWithExistingSession() throws { func testHandshakeAlwaysAcceptedWithExistingSession() throws {
// Test that handshake is always accepted even with existing valid session // Test that handshake is always accepted even with existing valid session
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey) let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
// Establish sessions // Establish sessions
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
@@ -453,8 +460,8 @@ final class NoiseProtocolTests: XCTestCase {
func testNonceDesynchronizationCausesRehandshake() throws { func testNonceDesynchronizationCausesRehandshake() throws {
// Test that nonce desynchronization leads to proper re-handshake // Test that nonce desynchronization leads to proper re-handshake
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey) let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
// Establish sessions // Establish sessions
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
@@ -499,8 +506,8 @@ final class NoiseProtocolTests: XCTestCase {
func testHandshakePerformance() throws { func testHandshakePerformance() throws {
measure { measure {
do { do {
let alice = NoiseSession(peerID: "bob", role: .initiator, localStaticKey: aliceKey) let alice = NoiseSession(peerID: "bob", role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey)
let bob = NoiseSession(peerID: "alice", role: .responder, localStaticKey: bobKey) let bob = NoiseSession(peerID: "alice", role: .responder, keychain: mockKeychain, localStaticKey: bobKey)
try performHandshake(initiator: alice, responder: bob) try performHandshake(initiator: alice, responder: bob)
} catch { } catch {
XCTFail("Handshake failed: \(error)") XCTFail("Handshake failed: \(error)")
@@ -530,12 +537,14 @@ final class NoiseProtocolTests: XCTestCase {
aliceSession = NoiseSession( aliceSession = NoiseSession(
peerID: TestConstants.testPeerID2, peerID: TestConstants.testPeerID2,
role: .initiator, role: .initiator,
keychain: mockKeychain,
localStaticKey: aliceKey localStaticKey: aliceKey
) )
bobSession = NoiseSession( bobSession = NoiseSession(
peerID: TestConstants.testPeerID1, peerID: TestConstants.testPeerID1,
role: .responder, role: .responder,
keychain: mockKeychain,
localStaticKey: bobKey localStaticKey: bobKey
) )
+1 -1
View File
@@ -10,7 +10,7 @@ import Foundation
import CryptoKit import CryptoKit
@testable import bitchat @testable import bitchat
class TestHelpers { final class TestHelpers {
// MARK: - Key Generation // MARK: - Key Generation
+5
View File
@@ -32,6 +32,8 @@ targets:
CFBundleVersion: $(CURRENT_PROJECT_VERSION) CFBundleVersion: $(CURRENT_PROJECT_VERSION)
NSBluetoothAlwaysUsageDescription: bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users. NSBluetoothAlwaysUsageDescription: bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.
NSBluetoothPeripheralUsageDescription: bitchat uses Bluetooth to discover and connect with other bitchat users nearby. NSBluetoothPeripheralUsageDescription: bitchat uses Bluetooth to discover and connect with other bitchat users nearby.
NSCameraUsageDescription: bitchat uses the camera to scan QR codes to verify peers.
NSLocationWhenInUseUsageDescription: bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.
UIBackgroundModes: UIBackgroundModes:
- bluetooth-central - bluetooth-central
- bluetooth-peripheral - bluetooth-peripheral
@@ -89,6 +91,8 @@ targets:
LSMinimumSystemVersion: $(MACOSX_DEPLOYMENT_TARGET) LSMinimumSystemVersion: $(MACOSX_DEPLOYMENT_TARGET)
NSBluetoothAlwaysUsageDescription: bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users. NSBluetoothAlwaysUsageDescription: bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.
NSBluetoothPeripheralUsageDescription: bitchat uses Bluetooth to discover and connect with other bitchat users nearby. NSBluetoothPeripheralUsageDescription: bitchat uses Bluetooth to discover and connect with other bitchat users nearby.
NSCameraUsageDescription: bitchat uses the camera to scan QR codes to verify peers.
NSLocationWhenInUseUsageDescription: bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.
CFBundleURLTypes: CFBundleURLTypes:
- CFBundleURLSchemes: - CFBundleURLSchemes:
- bitchat - bitchat
@@ -126,6 +130,7 @@ targets:
platform: iOS platform: iOS
sources: sources:
- bitchatShareExtension - bitchatShareExtension
- bitchat/Services/TransportConfig.swift
info: info:
path: bitchatShareExtension/Info.plist path: bitchatShareExtension/Info.plist
properties: properties: