Compare commits

..
Author SHA1 Message Date
jackandGitHub 4b38e9a23c Merge pull request #902 from jackjackbits/refactor/transport-abstraction
Refactor MessageRouter to use generic Transport abstraction
2025-11-26 23:10:32 -10:00
jack 2686d9c82c Increase test sleep duration to fix flaky CI test 2025-11-26 19:25:47 -10:00
jack 832e1f006c Fix startup race in NostrTransport reachability cache 2025-11-26 18:48:08 -10:00
jackandGitHub 090c4cd919 Merge branch 'main' into refactor/transport-abstraction 2025-11-26 18:42:13 -10:00
jack 37c2b3a8c2 Refactor MessageRouter to use generic Transport abstraction
- Decouple MessageRouter from specific Mesh/Nostr implementations
- Update NostrTransport to implement isPeerReachable using a local cache of favorited peers
- Update ChatViewModel to inject transports as a list
- Mark NostrTransport as @unchecked Sendable to handle internal thread safety
2025-11-26 18:39:35 -10:00
jackandGitHub 6f1c879237 Merge pull request #900 from anthonybiasi/fix/macos-environment-object-sheets
fix(macos): Propagate environment objects to sheet presentations
2025-11-26 18:37:33 -10:00
jackandGitHub 81f5101390 Merge branch 'main' into fix/macos-environment-object-sheets 2025-11-26 18:21:12 -10:00
jackandGitHub d56674706d Merge pull request #901 from jackjackbits/refactor/chatviewmodel
Refactor ChatViewModel and fix private chat bugs
2025-11-26 18:20:56 -10:00
jack 33bcfa3147 Fix build warnings: exclude README and fix unused variable 2025-11-26 15:26:48 -10:00
jack 3a54160793 Fix unused variable warning in tests 2025-11-26 15:22:09 -10:00
jack 5d2745eae6 Add tests for blocking and migration logic 2025-11-26 15:06:53 -10:00
jack 5138ce4a33 Add tests for ChatViewModel extensions 2025-11-26 12:47:56 -10:00
jack 07a4997192 Fix private chat message handling: restore persistence and notifications 2025-11-26 12:40:44 -10:00
jack 2d703f4dd9 Refactor ChatViewModel into extensions for Nostr, Tor, and Private Chat 2025-11-26 09:59:42 -10:00
Anthony Biasi 56b9457fcf fix(macos): Propagate ChatViewModel environment object to sheet presentations
Fixes crash on macOS when presenting sheets due to missing @EnvironmentObject.
Added .environmentObject(viewModel) to 8 sheet/fullScreenCover presentations.

Fixed presentations:
- AppInfoView sheet
- FingerprintView sheet
- ImagePickerView fullScreenCover (iOS, both contexts)
- MacImagePickerView sheet (macOS, both contexts)
- ImagePreviewView sheet
- LocationChannelsSheet sheet

Tested on macOS 26.1 (Apple Silicon M4).
All sheet presentations now open without crash.

Platform: macOS only
Impact: Resolves EnvironmentObject.error() crashes
2025-11-26 08:25:23 -07:00
jackandGitHub 9e57bce5c3 Merge pull request #898 from permissionlesstech/refactor/chatviewmodel-extraction
Refactor ChatViewModel: extract logic to services
2025-11-25 10:28:51 -10:00
jack 6eef030386 Fix: use persisted read receipts during consolidation
Pass the UserDefaults-backed sentReadReceipts from ChatViewModel to
consolidateMessages() to correctly identify already-read messages
after app restart. This prevents duplicate read receipts and incorrect
unread badges when reopening a chat shortly after reading it.

Addresses PR review feedback.
2025-11-25 10:24:24 -10:00
jack 9d8754099d Refactor ChatViewModel: extract logic to services
- Remove duplicate Regexes enum, use MessageFormattingEngine.Patterns
- Delete unused formatMessage() function (views use formatMessageAsText)
- Move message consolidation logic to PrivateChatManager
- Add consolidateMessages() and syncReadReceiptsForSentMessages()
- Wire PrivateChatManager to UnifiedPeerService for peer lookup

Reduces ChatViewModel from 5998 to 5713 lines (-285 lines)
All 303 tests pass
2025-11-25 10:16:09 -10:00
jackandGitHub 7fae4e71fb Merge pull request #897 from permissionlesstech/fix/swift6-concurrency-warnings
Fix Swift 6 concurrency warnings
2025-11-25 09:56:17 -10:00
jack 76d8b3fa7f Fix Swift 6 concurrency warnings
- ContentView: Wrap Timer callbacks in Task { @MainActor in } to
  properly access main actor-isolated properties (updateAutocomplete,
  messages)
- BitchatApp: Capture nickname before entering background queue to
  avoid accessing main actor-isolated property from Sendable closure
2025-11-25 09:50:02 -10:00
jackandGitHub b8a8b940b7 Merge pull request #896 from permissionlesstech/refactor/chatviewmodel-testability
Add ChatViewModel testability infrastructure and unit tests
2025-11-25 09:45:09 -10:00
jack 7a5ab52f4c Skip CoreLocation setup in test environments
Add isRunningTests check to LocationStateManager to skip CoreLocation
delegate and permission initialization in test/CI environments.
This prevents potential blocking or unexpected behavior when tests
access LocationStateManager.shared.
2025-11-25 09:39:36 -10:00
jack d2edb651c1 Add CI environment detection to isRunningTests
Add checks for GITHUB_ACTIONS, CI, and XCTestBundlePath environment
variables to reliably detect test/CI environments where notification
APIs should be skipped.
2025-11-25 09:29:26 -10:00
jack 858e878959 Fix CI test failures by simplifying isRunningTests check
Remove Bundle.main.bundleIdentifier == nil from isRunningTests
detection as it may have unintended side effects in CI environments.
The XCTestCase class check and XCTestConfigurationFilePath environment
variable are sufficient for detecting both XCTest and Swift Testing.
2025-11-25 09:20:38 -10:00
jack fa0a15cbcf Retrigger CI 2025-11-25 09:20:38 -10:00
jack 3e680f40bf Add ChatViewModel testability and unit tests
- Add testable ChatViewModel initializer accepting Transport dependency
- Create MockTransport implementing full Transport protocol for testing
- Add 21 unit tests covering:
  - Initialization (delegate, services, nickname)
  - Message sending (basic, empty, mentions, commands)
  - Message receiving (delegate calls, public messages)
  - Peer connections (connect, disconnect, isPeerConnected)
  - Deduplication service integration
  - Private chat routing
  - Bluetooth state handling
  - Panic clear functionality
- Guard NotificationService methods against test environment crashes
- Make deduplicationService internal for test access

All 303 tests pass.
2025-11-25 09:20:38 -10:00
jackandGitHub ec3c16176e Merge pull request #894 from permissionlesstech/refactor/simplify-chatviewmodel-phase1
Refactor: Simplify ChatViewModel - Phase 1
2025-11-25 09:19:57 -10:00
jack 2a8de7e048 Consolidate duplicate handlePrivateMessage methods
Removed duplicate handlePrivateMessage(payload:senderPubkey:...) method
(~63 lines) that was nearly identical to handlePrivateMessage(_:senderPubkey:...).

Changes:
- Added missing isNostrBlocked check to the remaining method
- Updated call site to use _ parameter style
- Removed redundant implementation
2025-11-25 09:13:51 -10:00
jack e9dd30ccbf Extract MessageDeduplicationService from ChatViewModel
Extract message deduplication logic into a dedicated service:
- LRUDeduplicationCache: Generic LRU cache for O(1) lookup with periodic compaction
- ContentNormalizer: Static methods for normalizing content (URL simplification, whitespace collapse, djb2 hash)
- MessageDeduplicationService: Manages content, Nostr event, and ACK deduplication

This removes ~70 lines of inline LRU management code from ChatViewModel
and adds 42 comprehensive tests covering all deduplication scenarios.

Changes to ChatViewModel:
- Add deduplicationService dependency
- Remove normalizedContentKey(), recordContentKey(), trimContentLRUIfNeeded(), popOldestContentKey()
- Remove contentLRUMap, contentLRUOrder, contentLRUHead, contentLRUCap
- Remove processedNostrEvents, processedNostrEventOrder, processedNostrEventHead, maxProcessedNostrEvents
- Remove processedNostrAcks, recordProcessedEvent(), trimProcessedNostrEventsIfNeeded(), popOldestProcessedEvent()
- Remove unused Regexes.simplifyHTTPURL
- Update all call sites to use deduplicationService methods
2025-11-25 09:13:51 -10:00
jackandGitHub af28faa91d Merge pull request #893 from permissionlesstech/claude/simplify-codebase-012QAJ8ihBcE4VzdufcixKHt
Consolidate message deduplication into shared MessageDeduplicator
2025-11-25 09:10:16 -10:00
jack b33fe8086d Add testable initializer to LocationStateManager
Allow tests to create instances with custom UserDefaults storage
for isolated testing of bookmark functionality.
2025-11-25 08:08:51 -10:00
jack 5495874b5a Fix method name after LocationStateManager consolidation
Update call site to use resolveBookmarkNameIfNeeded instead of
resolveNameIfNeeded, which was renamed during the consolidation.
2025-11-25 08:02:48 -10:00
Claude 0fca551966 Consolidate LocationChannelManager and GeohashBookmarksStore into LocationStateManager
Merge two singleton location managers into a unified LocationStateManager:
- CoreLocation permissions and channel computation
- Channel selection and teleport state tracking
- Bookmark persistence and friendly name resolution
- Reverse geocoding (shared, deduplicated)

Provides backward-compatible typealiases so existing code continues to work:
- typealias LocationChannelManager = LocationStateManager
- typealias GeohashBookmarksStore = LocationStateManager

Reduces 4 location-related files to 3 (keeping LocationNotesManager and
GeohashParticipantTracker separate due to different lifecycles).

Files: 2 deleted, 1 created (~525 lines -> ~420 lines)
2025-11-25 01:54:40 +00:00
Claude 0492480598 Consolidate message deduplication into shared MessageDeduplicator
- Extend MessageDeduplicator with configurable maxAge/maxCount params
- Add timestampFor() method for content key time-window checks
- Add record() method to store entries with specific timestamps
- Replace ChatViewModel's custom contentLRU implementation with MessageDeduplicator
- Remove ~35 lines of duplicate LRU code from ChatViewModel

This consolidates 2 separate deduplication implementations into one
reusable, thread-safe component.
2025-11-25 01:19:26 +00:00
jackandGitHub 86f46c8b90 Merge pull request #892 from permissionlesstech/fix/thread-sleep-bleservice
Replace Thread.sleep with cooperative RunLoop delay
2025-11-24 11:57:53 -10:00
jackandGitHub 7057fe75b0 Merge branch 'main' into fix/thread-sleep-bleservice 2025-11-24 11:48:58 -10:00
jackandGitHub 873788b24f Merge pull request #891 from permissionlesstech/refactor/extract-message-formatting-engine
Extract MessageFormattingEngine from ChatViewModel
2025-11-24 11:48:45 -10:00
jack b6dd31b285 Extract MessageFormattingEngine from ChatViewModel
Refactor message formatting logic into a dedicated, testable class:

- Create MessageFormattingEngine with Patterns enum (precompiled regexes)
- Create MessageFormattingContext protocol for dependency injection
- Extract extractMentions(), containsCashuToken(), and pattern matchers
- Add 28 comprehensive tests for formatting utilities
- Update ChatViewModel to conform to MessageFormattingContext

This prepares for further message formatting extraction and makes
regex patterns and utility functions testable in isolation.
2025-11-24 11:44:59 -10:00
jackandGitHub ff7e5a2c2c Merge pull request #890 from permissionlesstech/refactor/extract-geohash-participant-tracker
Extract GeohashParticipantTracker from ChatViewModel
2025-11-24 11:43:29 -10:00
jack 708a5e33cd Replace Thread.sleep with cooperative RunLoop delay in BLEService
Replace blocking Thread.sleep in stopServices() with a cooperative
RunLoop-based delay. This allows BLE callbacks to fire during the
shutdown delay, improving reliability of leave message transmission.

The delay is needed to give the leave message time to transmit before
disconnecting peers and stopping the BLE stack.
2025-11-24 11:37:27 -10:00
jack f893f0605a Extract GeohashParticipantTracker from ChatViewModel
Refactor participant tracking logic into a dedicated, testable class:

- Create GeohashParticipantTracker with GeohashParticipantContext protocol
- Move GeoPerson struct to new file
- Extract participant recording, refresh, and count logic
- Add 18 comprehensive tests for the tracker
- Update ChatViewModel to use tracker via dependency injection
- Subscribe to tracker's objectWillChange for SwiftUI observation

This reduces coupling in ChatViewModel and makes participant tracking
testable in isolation.
2025-11-24 11:34:47 -10:00
jackandGitHub ce33132a0f Merge pull request #889 from permissionlesstech/refactor/extract-command-coordinator
Refactor: Break circular dependency between CommandProcessor and ChatViewModel
2025-11-24 11:11:57 -10:00
jackandGitHub c538837300 Merge branch 'main' into refactor/extract-command-coordinator 2025-11-24 11:06:32 -10:00
jackandGitHub 6888bfa351 Merge pull request #888 from permissionlesstech/fix/crypto-precondition-crashes
Fix: Replace precondition() crashes with throwing errors in crypto code
2025-11-24 11:06:14 -10:00
jack 2fed4b7c31 Fix XChaCha20 tests to avoid Swift Testing crash
The original tests using in-place Data modification with `data[0] ^= 0xFF`
caused signal 5 crashes during parallel test execution. Fixed by:
- Using `import struct Foundation.Data` for efficiency
- Converting Data to byte array before modification to avoid the crash
- Simplified error-throwing tests to use boolean flags instead of
  complex error type matching
2025-11-24 11:02:00 -10:00
jack b956e407ff Add unit tests for XChaCha20Poly1305Compat error handling
15 tests covering:
- Valid input roundtrip (seal/open with and without AAD)
- Different nonces produce different ciphertext
- Invalid key length throws error (short, long, empty)
- Invalid nonce length throws error (short, long, empty)
- Error details contain expected/got values
- Authentication fails with wrong key
- Authentication fails with tampered ciphertext
2025-11-24 10:43:19 -10:00
jack 477cc7fa05 Break circular dependency between CommandProcessor and ChatViewModel
Introduce CommandContextProvider protocol to define what CommandProcessor
needs from its context. This follows the Dependency Inversion Principle:

- Create CommandContextProvider protocol with 15 methods/properties
- Create CommandGeoParticipant struct for geo participant data
- Add getVisibleGeoParticipants() to ChatViewModel
- ChatViewModel now conforms to CommandContextProvider
- CommandProcessor depends on protocol, not concrete ChatViewModel

Benefits:
- Breaks the tight coupling between CommandProcessor and ChatViewModel
- Makes CommandProcessor more testable (can use mock context)
- Clear contract for what CommandProcessor needs
- Backwards-compatible via chatViewModel property alias

Also fixes a concurrency bug in BitchatApp where notification delegate
was accessing main-actor-isolated property from arbitrary thread.
2025-11-24 10:34:29 -10:00
jack dffce9d63f Replace precondition() with throwing errors in XChaCha20Poly1305Compat
precondition() crashes the app in release builds if violated. This is
dangerous for cryptographic code that may receive malformed input from
network data or key derivation bugs.

Changed to guard statements that throw typed errors instead:
- XChaCha20Poly1305Compat.Error.invalidKeyLength
- XChaCha20Poly1305Compat.Error.invalidNonceLength

This allows callers to handle errors gracefully rather than crashing.
2025-11-24 10:22:51 -10:00
jackandGitHub 792eaa3166 Merge pull request #884 from Volgat/fix-analysis-errors
Improve error handling in NostrTransport
2025-11-24 09:46:33 -10:00
jackandGitHub 6ff9b59ff2 Merge branch 'main' into fix-analysis-errors 2025-11-24 09:46:21 -10:00
jackandGitHub bc04cd0bde Merge pull request #879 from xingheng/main
Correct the scheme name for macOS target.
2025-11-24 09:43:37 -10:00
jackandGitHub 3fe417395e Merge branch 'main' into main 2025-11-24 09:37:01 -10:00
jackandGitHub e35bd57c6e Merge pull request #885 from nadimkobeissi/noise-tests
Test Vector Support for Noise XX Handshake
2025-11-24 09:35:00 -10:00
Nadim Kobeissi 5aefb05a8b Fix Noise test vector compatibility with swift test 2025-11-18 21:58:46 +02:00
Nadim Kobeissi e4cbf382ce Fix unintended changes to project.pbxproj 2025-11-18 21:53:54 +02:00
Nadim Kobeissi 7609c6eeba Fix unintended changes to project.pbxproj 2025-11-18 21:53:27 +02:00
Nadim Kobeissi f37acf7e4b Finish up Noise tests 2025-11-18 21:31:55 +02:00
Nadim Kobeissi cf528b0daf Move new Noise test vectors into XCTests (WIP) 2025-11-18 19:22:19 +02:00
Nadim Kobeissi 209ccb4ade Remove obsolete README 2025-11-18 19:21:29 +02:00
Nadim Kobeissi 1876e85f8b Move new Noise test vectors into XCTests (WIP) 2025-11-18 19:20:16 +02:00
Nadim Kobeissi 109b7d0e03 Move new Noise test vectors into XCTests (WIP) 2025-11-18 19:15:54 +02:00
Nadim Kobeissi 09a319fb0f Initial work on adding Noise test vectors
I only became aware halfway through working on these that
`bitchatTests/Noise` exists, so the next step is to integrate them over
there.
2025-11-18 18:44:57 +02:00
Claude 97ca55cc54 Improve error handling in NostrTransport
Add proper error logging for Bech32 decode failures instead of silently
returning. This improves debuggability by making it clear when and why
npub decoding fails for favorite notifications, delivery acks, and read
receipts.

The previous empty catch blocks made it difficult to diagnose issues
with malformed or invalid npub addresses. Now all decoding errors are
properly logged with context about which operation failed.
2025-11-18 16:26:12 +00:00
Will Han 394836f247 Correct the scheme name for macOS target. 2025-11-07 00:06:24 +08:00
97fc21c23f Add localizable strings in show commands view (#828)
* Add localizable strings in show commands view n refactor code to remove duplicate string declarations.

* Modify struct to enum in separate file to Models/CommandsInfo.

* Add corrections code in refactory and enum.

* Correct code in command enum and ContentView.

* Dedicated CommandSuggestionsView to extract code

* Simplify CommandInfo + minor optimization

* Fix preview

---------

Co-authored-by: islam <2553451+qalandarov@users.noreply.github.com>
2025-10-29 22:09:43 +00:00
Jithin RenjiandGitHub 79bd4af912 Remove redundant conditional compilation directive (#863) 2025-10-29 16:02:34 +00:00
96c78ef5a2 Fix verification persistence on iOS when app backgrounds (#822)
* Fix verification persistence on iOS when app backgrounds (#785)

Verification status was not being saved when the iOS app entered background
state, causing verified contacts to lose their verification status after the
app was closed. This happened because iOS can terminate backgrounded apps
without calling applicationWillTerminate.

Changes:
- Add saveIdentityState() method to force-save identity data without stopping services
- Call saveIdentityState() when iOS app enters background state
- Refactor applicationWillTerminate() to use new method
- Fix selector name typo (appWillTerminate -> applicationWillTerminate)

The verification data is now properly persisted to encrypted keychain storage
whenever the app backgrounds, ensuring verification status persists across
app launches.

Fixes #785

* Save identity state during verification events

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-28 19:19:00 +01:00
01256f3041 Add source-based routing support (#862)
* Add source-based routing support

* include neighbors in ANNOUNCE

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
2025-10-28 12:38:44 +01:00
2edbe2bbbd Extract public message pipeline from ChatViewModel (#870)
* Extract public timeline helpers and rate limiter

* Extract public message pipeline

* Fix Swift concurrency warnings

* Incorporate public chat review feedback

* Tighten nostr key removal loop

* Address review feedback

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-28 12:10:09 +01:00
jackandGitHub a91978c10e Extract public timeline helpers and rate limiter (#869) 2025-10-27 14:13:57 +01:00
14c4e586fc Improve background BLE stability and nearby alerts (#864)
* Harden background BLE restoration and notifications

* Guard BLE restoration paths to iOS

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-23 19:54:06 +02:00
83779240ae Prevent mesh self-sync duplicates (#856)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-23 10:41:28 +02:00
5034732515 Simplify validation, compression heuristics, and notification scheduling (#841)
* Simplify validation, compression heuristics, and notification scheduling

* Consolidate notification logic and add InputValidator monitoring

Follow-up improvements to address PR feedback:

1. Consolidate notification functions
   - Add interruptionLevel parameter to sendLocalNotification
   - Refactor sendNetworkAvailableNotification to use consolidated function
   - Removes 12 lines of duplicate code

2. Add monitoring to InputValidator
   - Log control character rejections for production monitoring
   - Privacy-preserving: logs length + count, not actual content
   - Uses .security category for proper log routing

3. Add comprehensive InputValidator tests
   - 28 test cases covering validation, control characters, unicode, edge cases
   - Ensures behavioral changes are well-tested and documented

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-22 12:33:37 +02:00
b6ce4fae43 Add type-aware REQUEST_SYNC sync rounds (#853)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-22 12:24:29 +02:00
98fa02cc16 Prevent duplicate action emote echoes (#852)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-21 13:47:24 +02:00
0812cafd55 Improve mesh media throughput (#845)
* Improve mesh media throughput

* Reserve media slots atomically

* Prioritize small fragment trains

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-21 12:53:54 +02:00
IslamandGitHub 450955525c No fallback mime-type + remove dead code (#844) 2025-10-20 01:50:33 +02:00
IslamandGitHub 475bc70c71 Extract MimeTypes into a separate file + add tests (#843) 2025-10-20 01:21:23 +02:00
IslamandGitHub af6136c01c Minor cleanup (#842) 2025-10-20 01:11:44 +02:00
b839ce5f6c PeerID 31/n: Remove String interop to be explicit (#840)
* PeerID 28/n: `ChatViewModel.getShortIDForNoiseKey`

* PeerID 29/n: `BLEService` + remove dupe funcs from #823

* `handleFileTransfer` to use PeerID

* `sendMessage` and `sendPrivateMessage`

* PeerID 30/n: Update some leftovers

* `lowercased()` inside PeerID for normalization

* PeerID 31/n: Remove String interop to be explicit

* MockBLEService: Remove direct target delivery

This causes a delivery even if the sender and receiver are not connected

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-19 20:50:44 +02:00
0776c9813c PeerID 30/n: Update some leftovers + case normalization (#839)
* PeerID 28/n: `ChatViewModel.getShortIDForNoiseKey`

* PeerID 29/n: `BLEService` + remove dupe funcs from #823

* `handleFileTransfer` to use PeerID

* `sendMessage` and `sendPrivateMessage`

* PeerID 30/n: Update some leftovers

* `lowercased()` inside PeerID for normalization

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2025-10-19 20:40:45 +02:00
0dd999af6b PeerID 29/n: BLEService + remove dupe funcs from #823 (#838)
* PeerID 28/n: `ChatViewModel.getShortIDForNoiseKey`

* PeerID 29/n: `BLEService` + remove dupe funcs from #823

* `handleFileTransfer` to use PeerID

* `sendMessage` and `sendPrivateMessage`

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2025-10-19 20:35:41 +02:00
IslamandGitHub c3a1af7023 PeerID 28/n: ChatViewModel.getShortIDForNoiseKey (#836) 2025-10-19 20:30:10 +02:00
880813f256 Fix GeoRelay prefetch isolation (#837)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-19 17:24:17 +01:00
64fb634166 Fix camera icon in DM sheets using parent-child presentation pattern (#834)
Implements mutually-exclusive image picker presentations to fix the error
"Currently, only presenting a single sheet is supported" when tapping
camera in a DM.

Solution:
- ContentView: Only presents image picker when NOT in a sheet
  (when showSidebar=false and no private chat active)
- peopleSheetView: Only presents image picker when IN a sheet
  (when showSidebar=true or private chat active)

This ensures only ONE presentation is active at any time, preventing
conflicts. Uses fullScreenCover on iOS to allow presentation over sheets.

Addresses feedback from @qalandarov in PR #834 with minimal approach.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-19 15:23:05 +02:00
IslamandGitHub 13b19fb8eb PeerID 27/n: ContentView and MeshPeerList (#835) 2025-10-19 14:42:50 +02:00
IslamandGitHub d4967ae9c3 PeerID 26/n: FingerprintView (#833) 2025-10-19 14:36:06 +02:00
IslamandGitHub 435744a977 PeerID 25/n: ReadReceipt (#832) 2025-10-19 14:30:27 +02:00
IslamandGitHub 70caa9e24a PeerID 24/n: Nostr Transport and Embedding (#830) 2025-10-19 13:54:32 +02:00
5084f87fe5 Improve geohash media gating and relay refresh (#831)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-19 13:48:33 +02:00
lollerfirstandGitHub 8f56e4f0fb no longer commit into main. open PRs instead (#829) 2025-10-19 12:23:11 +02:00
aca44f9f55 Extract sanitization logic into an extensions + add tests (#827)
Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2025-10-19 11:34:21 +02:00
IslamandGitHub 3f00cf9467 Remove presentationDetents as default is already large (#826) 2025-10-19 11:16:57 +02:00
40e54a5120 Add voice notes and images over BLE mesh (audio + image only) (#823)
* Add BLE file transfer support and media UX

* Gracefully disable mac attachment pickers in sandbox

* Tighten spacing above media message bubbles

* Reduce vertical padding between chat rows

* Restore iOS file importer for attachments

* Copy imported files before sending to preserve access

* Allow file transfers from connected but unverified peers

* Raise BLE notification buffer cap for large file transfers

* Revert "Raise BLE notification buffer cap for large file transfers"

This reverts commit b624523af843475db84e4a846db8dcbe824ae408.

* Add guard to drop oversized BLE notification assemblies

* Let BLE assembler accept large frames up to hard cap

* Add detailed logging for BLE fragment assembly

* Log incomplete BLE frames for debugging

* Stop dropping partial BLE frames while assembling notifications

* Fix compressed BLE file transfers

* Enable mac attachment importers

* Allow mac microphone access

* Permit mac media library access

* Describe microphone usage

* Harden attachment transfer bookkeeping

* Display recording milliseconds

* Restore mac photo picker access

* Use Photos picker on mac

* Allow long-press reblur on images

* Reblur images via swipe

* Lowercase image preview buttons

* Keep processed images for outgoing messages

* Use save panel for mac image export

* Fix image attachment detection

* Allow user-selected write access

* Align mac image JPEG encoding

* Revert unsupported JPEG option

* Strip metadata in mac image encoding

* Normalize mac JPEG color space

* Target image byte size across platforms

* Fix CFMutableData handling

* Preserve packet version when signing

* Use unique transfer identifiers

* Stub file transfer methods in mock

* Stub file transfer methods in mock

* Hide absolute paths in media messages

* Resolve image/voice path handling

* Fix cleanupLocalFile lookup

* Restore BLE broadcasts when notify buffer is saturated

* Guard peer map reads on BLE message path

* Drop attachment ceilings to 1 MiB and bump release version

* Reset BLE assembler on stalled fragment trains

* Fix binary protocol test fixtures

* Fix critical issues from PR #681 review

Critical fixes:
- BinaryProtocol: Return nil for unknown versions (prevents buffer underflows)
- Add BinaryProtocol.Offsets struct to centralize magic numbers
- Replace magic offset calculations with named constants

Security/Privacy:
- FileAttachmentView: Use url.lastPathComponent instead of url.path
  (prevents exposing full system paths)

Documentation:
- Fix compression algorithm documentation (zlib, not LZ4)

All tests passing.

* Fix UI freeze when receiving voice notes

Problem: AVAudioPlayer initialization in VoiceNotePlaybackController.init()
was running synchronously on main thread during view creation, blocking
UI for 50-200ms per voice note.

Solution:
- Remove eager preparePlayer() call from init
- Load duration asynchronously on background queue
- Player is only prepared when playback is actually requested via ensurePlayerReady()

This prevents UI freezes when voice notes appear in the chat.

* Fix memory leaks and post-playback freeze

Fixes:
1. Post-playback freeze: audioPlayerDidFinishPlaying now dispatches to main
   thread before updating @Published properties (Swift concurrency violation)

2. Unbounded waveform cache: Implement LRU eviction with 20-entry limit
   - Track last access time for each cached waveform
   - Evict oldest entry when cache is full
   - Prevents unlimited memory growth as voice notes accumulate

3. Audio buffer memory leaks: Wrap computeWaveform in autoreleasepool
   - AVAudioPCMBuffer allocations are autoreleased
   - Pool ensures buffers are freed promptly

4. Image processing memory: Add autoreleasepool around compression loops
   - Each jpegData() call creates temporary objects
   - Inner pool per iteration prevents memory spikes during quality search

Memory should now remain stable during extended use.

* Eliminate disk I/O from SwiftUI view rendering path

Critical performance fix for UI freezes when receiving media:

Problem: mediaAttachment(for:) was called during every SwiftUI render,
performing synchronous disk I/O on main thread:
- FileManager.fileExists() called 2-6x per message (checking subdirs)
- applicationFilesDirectory() creating directories on every call
- With multiple media messages, this meant 20-100+ disk ops per render

Solution:
1. Remove fileExists checks - construct URLs directly
   - Files are validated during playback/display (fail gracefully if missing)
   - Sender determines subdirectory (outgoing vs incoming)

2. Cache applicationFilesDirectory() result
   - Static cache prevents repeated FileManager.url() calls
   - Directory created only once

3. Remove redundant playback.replaceURL() in VoiceNoteView.onAppear
   - Controller already initialized with correct URL

This eliminates ALL disk I/O from the view rendering hot path.

* Cache Nostr identity derivation to prevent crypto during view rendering

Critical performance fix:

Problem: formatMessageHeader() called deriveIdentity(forGeohash:) during
every SwiftUI render for every media message. Each call performed:
- Keychain I/O (getOrCreateDeviceSeed)
- HMAC-SHA256 computation
- Up to 10 secp256k1 key validations (elliptic curve crypto)

With multiple media messages, this resulted in 100s of milliseconds of
blocking crypto on main thread per render cycle.

Solution: Add thread-safe cache for derived identities
- Check cache before expensive crypto operations
- NSLock protects concurrent access
- Identity is deterministic per geohash, so caching is safe

This eliminates crypto from the hot rendering path.

* Cache geohash identity in ChatViewModel to prevent crypto during rendering

Additional optimization for location channels (voice notes are mesh-only,
but this helps with text message rendering in geohash channels):

- Add cachedGeohashIdentity to avoid deriveIdentity calls during rendering
- Check cache before falling back to crypto derivation
- Reduces main thread crypto work in location channels

* Make voice note loading completely lazy with deferred initialization

Aggressive performance optimization to prevent UI freezes:

Problem: Even with async loading, creating 10+ VoiceNotePlaybackController
instances simultaneously (when scrolling past multiple voice notes) spawned
20+ concurrent background tasks, potentially starving main thread.

Solution - Ultra-lazy loading:
1. VoiceNotePlaybackController.init() now does ZERO work
   - No duration loading
   - No player creation
   - Instant initialization

2. Duration loaded on-demand via public loadDuration() method
   - Called from VoiceNoteView.onAppear after 150ms delay
   - Reduced priority: .utility instead of .userInitiated
   - Guard prevents duplicate loading

3. Waveform loading also deferred 150ms
   - Gives UI time to settle after message appears
   - Prevents task storms when multiple voice notes appear

This spreads the work over time instead of all at once.

* Ensure /clear and panic triple-tap delete media files

Fix: /clear command and panicClearAllData() now properly delete media files

1. /clear (triple-tap on chat):
   - Deletes outgoing media (voice notes, images, files)
   - Conservative: only our sent media, preserves received media
   - Runs in background to avoid UI freeze

2. panicClearAllData() (triple-tap on bitchat/ header):
   - Deletes ALL media files (incoming + outgoing)
   - Removes entire files directory and recreates structure
   - Ensures complete data wipe for emergency scenarios

Both operations run async on .utility queue to prevent blocking UI.

* Fix infinite render loop and apply all security fixes

CRITICAL BUG FIX - Infinite Render Loop:

Root Cause: Duplicate view identity in ContentView.swift:368
  ForEach(messageItems) { item in  // Already uses item.id via Identifiable
      messageRow(...)
          .id(item.id)  //  REDUNDANT modifier caused identity re-evaluation loop
  }

When @Published properties updated, SwiftUI re-evaluated .id() → appeared as
'new' identity → triggered re-render → infinite loop. Caused UI freezes,
keyboard failures, and 100% CPU usage.

Fix: Remove redundant .id() modifier - ForEach already has stable identity.

PERFORMANCE FIXES:

1. Waveform Cache Deadlock (Waveform.swift)
   - Removed nested queue.async(barrier) on cache hits
   - Was causing task saturation and potential deadlocks

2. Async Send Pattern (ContentView.swift)
   - Clear input immediately, defer actual send to next runloop
   - Prevents blocking current event handler

3. Proper Swift Concurrency (VoiceNoteView.swift)
   - Switch from .onAppear + DispatchQueue to .task
   - Cleaner async/await pattern for loading

4. Remove Redundant objectWillChange (ChatViewModel.swift)
   - @Published already triggers updates automatically
   - Explicit send() was causing double update cycles

SECURITY FIXES (C1-C5, H1-H2):

C1. Path Traversal Protection (BLEService.swift)
    - Unicode normalization, null byte removal
    - Replace ALL path separators, reject dotfiles
    - Validate paths don't escape directory

C2. Integer Overflow (BitchatFilePacket.swift)
    - Use UInt64 for TLV parsing, safe Int conversion

C3. MIME Validation (BLEService.swift)
    - Whitelist: JPEG, PNG, GIF, WebP, M4A, MP3, WAV, OGG, PDF
    - Magic byte validation for all types
    - Lenient on M4A (platform variations)

C4. Compression Bomb (BinaryProtocol.swift)
    - Ratio validation <= 50,000:1
    - Defense-in-depth with 1MB size cap

C5. TOCTOU Race (ChatViewModel.swift)
    - Direct removeItem without fileExists check

H1. File Size Validation (ChatViewModel, ImageUtils)
    - Check attributes BEFORE Data(contentsOf:)
    - Prevents memory exhaustion

H2. Metadata Stripping (ImageUtils.swift)
    - Remove ALL metadata keys from JPEG encoding
    - Only compression quality set
    - Protects GPS/EXIF/device info privacy

RESULT:
 No render loops
 Works with Xcode debugger
 Voice notes display properly
 All security vulnerabilities fixed
 164 tests passing

Production ready.

* Complete all translations to 100% and fix auto-extraction

- Mark non-localizable strings with Text(verbatim:) to prevent extraction
- Update UI strings to lowercase per style guide (open, save, close, recording)
- Add complete translations for all 29 languages (194/194 strings at 100%)
- Remove empty/duplicate entries (@, bitchat/, Open, Recording %@)
- Add proper localization comments for all user-facing strings

* macOS: Focus message input on launch instead of nickname field

* Remove debug print statements from sendMessage

* Optimize voice note codec to 16 kHz / 20 kbps for smaller file sizes

- Reduce sample rate from 44.1 kHz to 16 kHz (telephony standard)
- Lower bitrate from 32 kbps to 20 kbps
- Results in ~37% file size reduction (~150 KB/min vs 240 KB/min)
- Increases max voice note length from 4.4 to 7 minutes over 1 MiB BLE limit
- Maintains excellent voice quality using native AAC-LC codec

* Fix critical security issues in fragment reassembly and file cleanup

Fragment Reassembly Race Condition (CRITICAL):
- Wrap all incomingFragments/fragmentMetadata access in collectionsQueue.sync
- Prevents concurrent modification crashes from multi-threaded access
- Minimizes lock contention by doing heavy work (reassembly/decode) outside locks
- Add upper bound check: reject fragments with total > 10,000 (DoS prevention)
- Add cumulative size validation before storing fragments (memory DoS prevention)

File Cleanup Path Traversal (CRITICAL):
- Use NSString.lastPathComponent to extract filename safely
- Prevents directory traversal attacks via malicious filenames
- Add path prefix validation before file deletion
- Now checks both incoming and outgoing directories (fixes disk leak)

Additional Protections:
- Fragment assemblies now limited by both count (128) and cumulative bytes (1MB)
- Explicit checks for "." and ".." filenames in cleanup
- Defense-in-depth: multiple validation layers

* Fix post-rebase compilation errors

- Remove duplicate NostrIdentityBridge and Bech32 from NostrIdentity.swift (now in separate files)
- Add caching to NostrIdentityBridge.deriveIdentity() for performance
- Remove duplicate NotificationStreamAssembler from BLEService.swift
- Remove duplicate function declarations in BLEService.swift
- Remove duplicate DeliveryStatusView and PaymentChipView from ContentView.swift
- Fix PeerID type conversions throughout (use .id for String, PeerID(str:) for wrapping)
- Update ContentView body to use main's simple VStack structure
- Fix NostrIdentityBridge instance method calls
- Remove privateChatView (replaced with sheet-based UI in main)

Build and tests passing (137/139 tests pass).

* Fix remaining compilation issues after rebase

- Fix PhotosUI import order (must be after platform imports)
- Fix Data.WritingOptions.atomic reference
- Add identity derivation caching to NostrIdentityBridge
- Fix all remaining PeerID type conversions in ChatViewModel
- Fix ContentView body structure to use main's VStack layout
- Fix PaymentChipView API usage (now uses PaymentType enum)

Build and tests now passing.

* Add proper availability checks for PhotosPickerItem

PhotosPickerItem requires iOS 16+ / macOS 13+ but canImport(PhotosUI)
succeeds on older macOS versions. Add compiler version check to ensure
PhotosPicker code only compiles when actually available.

This fixes CI build failures on older macOS environments.

* Limit PhotosPicker to iOS only to fix CI

PhotosPickerItem has SDK availability issues on macOS in CI.
Change PhotosPicker from canImport(PhotosUI) to os(iOS) only.

macOS users can still import images via file importer (.fileImporter).
This is actually cleaner as macOS file picker is more familiar to users.

Fixes CI build failures.

* Convert new tests to Swift Testing

* Fix compilation issue

* Add the missing `fileTransfer` case

* Explicitly list all Enum cases to get compile-time errors

* Revive lost `NotificationStreamAssembler` changes

* Allow file fragments to account for protocol overhead

* Simplify PR: Focus on audio+image, fix EXIF stripping, remove file transfers

- Fix critical EXIF privacy issue in iOS image processing
  - Both iOS and macOS now use CGImageDestination for metadata stripping
  - Shared encodeJPEG function ensures no GPS, camera, or metadata leaks

- Remove file transfer functionality to simplify PR scope
  - Deleted FileAttachmentView
  - Removed sendFileAttachment from ChatViewModel
  - Removed file picker UI from ContentView
  - Simplified attachment dialog to image only (iOS) or voice only

- Keep focused media features:
  - Voice recording and playback
  - Image sending with progressive reveal
  - Binary protocol for media transfer

* Improve camera UX: Direct camera access with camera icon

- Change paperclip icon to camera icon for clearer affordance
- Open camera directly on tap (no confirmation dialog)
- Add CameraPickerView wrapper for UIImagePickerController
- Remove PhotosPicker in favor of direct camera access
- Images still processed through ImageUtils with EXIF stripping
- Accessibility: Added 'Take photo' label

* Full-screen camera with photo library option

- Change to fullScreenCover for immersive camera experience
- Add action sheet with 'Take Photo' and 'Choose from Library' options
- Renamed ImagePickerView to support both camera and library sources
- Both options open full-screen for better UX
- Updated accessibility label to 'Add photo' (more accurate)

* Fix camera white bars with overFullScreen presentation

- Changed modalPresentationStyle from .fullScreen to .overFullScreen
- This should eliminate white bars at top/bottom on notched devices
- Explicitly set showsCameraControls and cameraOverlayView for camera mode

* Gesture-based photo access: Tap for library, long-press for camera

UX improvements:
- Tap camera icon → Photo library (common use case)
- Long press camera icon (0.3s) → Direct camera (quick photos)
- Removed action sheet entirely for cleaner flow
- Power users can long-press for instant camera access

This is more discoverable and eliminates an extra step in the UI.

* Simplify camera presentation to reduce frame errors

- Changed back to standard .fullScreen presentation
- Removed overFullScreen which was causing frame dimension errors
- Let iOS handle safe areas automatically (white bars are intentional)
- Reduces gesture gate timeout warnings

Note: White bars on notched devices are iOS default behavior for
UIImagePickerController. This respects safe areas for status bar
and home indicator. True edge-to-edge would require custom AVFoundation
camera implementation.

* Force dark mode on camera/picker for black safe area bars

- Set overrideUserInterfaceStyle = .dark on UIImagePickerController
- Changes white bars to black (much better looking)
- Camera controls and photo library also appear in dark mode
- Consistent dark appearance regardless of system settings

* Optimize sheet presentation for camera UI

- Force .large detent for maximum height
- Hide drag indicator for cleaner look
- Use ignoresSafeArea to give camera full space
- Should show complete flash button and controls

* Fix P1: Add DoS protections to PeerID fragment handler

Critical security fix addressing Codex review feedback:

The PeerID overload of handleFragment (which is actually called by
CoreBluetooth) was missing key safety checks that existed in the
String overload:

1. Added total <= 10000 check to prevent unbounded fragment counts
2. Added cumulative size check against FileTransferLimits before
   storing each fragment
3. Prevents memory exhaustion DoS attacks via malicious fragment streams

This ensures the actually-used code path has proper bounds checking.

* Fix decompression size limit to support max-sized file transfers

Root cause: BinaryProtocol.decode() was rejecting decompressed payloads
larger than maxPayloadBytes (1 MB), but TLV-encoded file transfers are
slightly larger due to metadata overhead.

Fixes:
- Changed decompression limit from maxPayloadBytes to maxFramedFileBytes
- This accounts for TLV overhead (~50 bytes) + binary protocol headers
- Now allows ~1.12 MB decompressed payloads (1 MB + overhead budget)

The failing test was:
- Creating 1 MB file content
- TLV encoding adds ~50 bytes (1,048,627 total)
- Compression reduces to ~1,084 bytes (highly repetitive data)
- During decode, decompression was rejecting the 1,048,627 byte output
- Now correctly allows it since 1,048,627 < 1,179,760 (maxFramedFileBytes)

All 154 tests now pass including 'Max-sized file transfer survives reassembly'

* Fix critical thread-safety crash in PeerID fragment handler

CRITICAL: The PeerID version of _handleFragment was accessing
incomingFragments dictionary without collectionsQueue synchronization,
causing crashes when multiple BLE threads processed fragments concurrently.

Crash stack trace pointed to line 3431 (dictionary subscript) with:
'doesNotRecognizeSelector' - classic concurrent mutation crash.

Fix:
- Wrapped ALL incomingFragments/fragmentMetadata access in
  collectionsQueue.sync(flags: .barrier)
- Matches the thread-safe pattern used in String version
- Separate cleanup into its own barrier block after reassembly
- Prevents concurrent dictionary mutations from multiple BLE threads

This is the same pattern as the String version (line 1128) which didn't crash.

* Remove Localizable.xcstrings formatting noise

The Localizable.xcstrings file had massive formatting-only changes
(spacing: 'key' vs 'key :') that added 50K+ lines to the PR diff.

This was just Xcode reformatting with no actual string changes.

Reverted to main's version to keep PR focused on actual code changes.

* Add macOS photo picker support

- Added MacImagePickerView with NSOpenPanel for macOS
- macOS shows photo.circle.fill icon (no camera hardware)
- Opens native file picker for images (.png, .jpeg, .heic)
- Images processed through ImageUtils with EXIF stripping
- Simple sheet with Select/Cancel buttons

Cross-platform photo sharing now works:
- iOS: Tap for library, long-press for camera
- macOS: Tap for file picker

* Add Localizable strings for camera and voice features

Xcode auto-generated localization strings for new UI elements:
- Camera/photo picker labels
- Voice recording UI strings
- Media attachment descriptions

These are legitimate new strings needed for the audio+image feature,
not just formatting changes.

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: islam <2553451+qalandarov@users.noreply.github.com>
2025-10-18 20:55:33 +02:00
7040d9ecb0 Add plus-minus feature to location notes (± 1 grid) (#821)
Expands location notes coverage to include 8 neighboring geohash cells,
creating a 3×3 grid around the user's current building-level location.

Changes:
- Add Geohash.neighbors() method to calculate 8 surrounding cells
- Update LocationNotesManager to subscribe to center + neighbors (9 cells total)
- Add NostrFilter.geohashNotes([String]) overload for multi-cell subscriptions
- Display '± 1' indicator in LocationNotesView header

Coverage:
- Single cell: ~38m × 19m
- 3×3 grid: ~114m × 57m total area
- Better discovery of nearby activity

Behavior:
- Subscribe: Fetches notes from all 9 cells (single efficient subscription)
- Post: Still uses center geohash only (correct privacy-preserving behavior)
- UI: Shows 'geohash ± 1' to indicate expanded coverage

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-18 12:44:06 +02:00
88bafb41cc Remove LocationNotesCounter for privacy-first approach (#820)
Simplifies geonotes architecture by eliminating all background subscriptions:

- Delete LocationNotesCounter.swift (104 lines) and related tests (58 lines)
- Remove background subscription system entirely
- Icon is now constant darker orange (indicates availability, not state)
- Subscription ONLY happens when user explicitly opens the sheet
- Total: ~220 lines of code removed

Privacy Benefits:
- Zero network traffic until user action
- No location leaking to relays via background polling
- User must explicitly opt-in to discover notes

The icon (note.text in orange) simply indicates the feature is available
when location permission is granted. Notes are only fetched when the
sheet is opened, prioritizing privacy over convenience.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-18 12:06:31 +02:00
jackandGitHub fb43a8b0f5 Reuse cached mention regex in parseMentions (#812) 2025-10-17 23:02:58 +02:00
jackandGitHub eb35608fa1 Remove unused helpers and add cross-platform logging fallbacks (#811) 2025-10-17 22:58:56 +02:00
RedThoroughbredandGitHub b81ae0b4c0 fix: improve Xcode detection in Justfile (#814)
- Add check for full Xcode vs command line tools only
- Provide clearer error messages with setup instructions
- Verify Xcode is installed and properly configured
- Add better validation for development environment

Fixes issue where 'just run' fails with cryptic error when only
command line tools are installed. Now gives clear guidance on
installing full Xcode and configuring xcode-select properly.

Resolves #760
2025-10-17 21:37:51 +02:00
jackandGitHub b6d42261d0 Guard peer collision checks with snapshot (#810) 2025-10-15 16:12:16 +02:00
3d914dcf46 Convert the remaining tests to Swift Testing (#781)
* SwiftTesting: NoiseProtocolTests + BinaryProtocolPaddingTests

* SwiftTesting: `NotificationStreamAssemblerTests`

* SwiftTesting: `NostrProtocolTests`

* SwiftTesting: `BinaryProtocolTests`

* SwiftTesting: `PeerIDTests`

* SwiftTesting: `BLEServiceTests`

* SwiftTesting: `CommandProcessorTests`

* SwiftTesting: `GCSFilterTests`

* SwiftTesting: `GeohashBookmarksStoreTests`

* Remove `peerID` test constants

* Remove PeerID + String interop from tests

* Refactor IntegrationTests to extract state management

* Refactor global state management of MockBLEService

* NoiseProtocolSwiftTests: `actor` -> `struct`

* Remove measurement tests w/ no benchmark

* `NoiseProtocolSwiftTests` -> `NoiseProtocolTests`

* SwiftTesting: `LocationChannelsTests`

* SwiftTesting: `GossipSyncManagerTests`

* SwiftTesting: `LocationNotesManagerTests`

* Global `sleep` function for tests

* SwiftTesting: `IntegrationTests`

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-15 01:04:01 +02:00
b3ec5eeda0 Refactor Noise: Extract files and remove dead code (#806)
* Extract each type to a separate file

* NoiseSessionManager: Remove unused functions

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2025-10-15 00:32:44 +02:00
IslamandGitHub 47d75ab9d8 PeerID 23/n: ChatViewModel + its dependences (#801) 2025-10-15 00:20:19 +02:00
3479c7d5df Fix people sheet dismiss gestures (#803)
* Allow closing people sheet from X and swipe

* Swipe right to return from DM to people list

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-14 21:11:44 +02:00
8a0727fcf7 Guard BLE link state lookups on BLE queue (#805)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-14 21:11:07 +02:00
6588861e34 Align gossip sync stale cleanup with Android client (#798)
* Align DM sheet toolbar with people list

* Gate stale gossip announcements

* Remove stale peer messages during gossip cleanup

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-14 13:54:36 +02:00
a1647901e5 Add Turkish translations for share extension & Add Turkey to knownRegions (#787)
* Add Turkish translations for share extension

* Add Turkey to knownRegions

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2025-10-14 12:45:23 +02:00
IslamandGitHub 23249f3e41 PeerID 22/n: PrivateChatManager (#800)
* PrivateChatManager: functions to use `PeerID`

* PrivateChatManager: properties to use `PeerID`
2025-10-14 12:30:18 +02:00
ad4103bacc Refactor Nostr ID Bridge & Keychain Helper (#796)
* Extract each type to a separate file

* Nostr ID Bridge: Convert static func/vars to instance

* `KeychainHelper` behind a protocol to easily mock

* Update tests with ID Bridge and MockKeychainHelper

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2025-10-14 12:26:18 +02:00
e3149fa098 Process incoming fragments on message queue (#804)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-14 12:20:39 +02:00
jack 987ba2e694 Fix people sheet close button 2025-10-12 20:18:29 +02:00
615273a63e Align DM sheet toolbar with people list (#795)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-12 19:53:08 +02:00
IslamandGitHub 4563c22d2d Fix hidden source of deadlock (#794) 2025-10-12 12:27:33 +02:00
IslamandGitHub 9f74266527 Centralize repeated queue-checking logic (#791) 2025-10-11 21:38:04 +02:00
IslamandGitHub 239064e4eb Cleanup (#793)
* Remove and ignore `.cache/`

* Optimize debug logo + remove its duplicates
2025-10-11 21:28:28 +02:00
IslamandGitHub d371131ad5 Remove MockBluetoothMeshService (#777) 2025-10-09 23:47:01 +02:00
d994ccf012 Fix send button tap responsiveness and sidebar drag jitter (#783)
Restructured ContentView layout to prevent sidebar from covering input box
and removed gesture conflicts that caused jitter during slow drags.

Changes:
- Moved sidebar overlay to only cover messages area, not input box
- Input box now always accessible below sidebar (not covered by overlay)
- Removed blocking drag gesture from mainChatView
- Changed sidebar gesture from simultaneousGesture to gesture for priority
- Removed animation-disabling transactions that amplified touch noise
- Removed 2pt threshold checks that caused visible jumps

Result: Send button taps immediately, sidebar slides smoothly without jitter.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-09 23:45:05 +02:00
IslamandGitHub 302c741d58 PeerID 21/n: UnifiedPeerService (#772) 2025-10-07 16:37:47 +02:00
IslamandGitHub 7ec84857eb PeerID 20/n: BLEService’s private functions (#771) 2025-10-07 16:26:17 +02:00
IslamandGitHub 68482c67d7 PeerID 19/n: BLEService’s private properties (#770) 2025-10-07 16:17:29 +02:00
IslamandGitHub 551a843691 PeerID 18/n: BitchatDelegate + Tests (#769) 2025-10-07 16:01:53 +02:00
IslamandGitHub fbc15ea08f SwiftTesting: Enable in GitHubActions + peer uuids (#767) 2025-10-07 12:40:20 +02:00
0f23ed0a99 Location notes: fix performance and UI issues (#774)
* Location notes: fix performance and UI issues

Performance fixes:
- Add Set-based duplicate detection (O(1) vs O(n) lookup)
- Eliminates lag when receiving 200+ notes during EOSE

Correctness fixes:
- Fix optimistic echo timestamp to match signed event timestamp
- Add echo IDs to noteIDs set for consistency

UI improvements:
- Remove duplicate "loading recent notes" text in header
- Simplify toolbar icon color logic for immediate green indication
- Icon now reliably turns green when notes exist in geohash

Tests: All 3 LocationNotes tests passing

* Location notes: add remaining robustness fixes

Additional improvements:
- Align counter/manager limits to 200 (prevents showing count higher than displayable)
- Set loading state before clearing notes to eliminate UI flicker on geohash change
- Add geohash validation for building-level precision (8 valid base32 chars)
- Add defensive 500-note memory cap with automatic trimming
- Clear stale notesGeohash state on sheet dismiss

Tests:
- Fix test geohashes to use valid base32 characters
- All 3 LocationNotes tests passing

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-07 12:36:54 +02:00
c583949031 Fix QR verification sending multiple notifications (#773)
The camera scanner was continuously detecting the same QR code (10-30+ times/second), causing multiple verification notifications to be sent. This happened because:

1. AVCaptureMetadataOutput fires repeatedly while QR is visible
2. Each detection triggered a new verification flow
3. After receiving response, pendingQRVerifications was cleared, allowing duplicate scans

Changes:
- Add deduplication using lastValid state to ignore re-scans of same QR code
- Only set lastValid after successful verification initiation
- Add onSuccess callback to close scanner after successful scan
- Automatically return to "My QR" view after verification starts
- Apply same logic to both iOS camera and macOS manual input paths

This ensures exactly one verification request per scan session.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-07 11:32:57 +02:00
IslamandGitHub d75700fa2b Add Tor’s xcframework and select “Do not embed” (#768) 2025-10-07 02:01:24 +02:00
7149182c56 Fix ghost peers and stale messages from gossip sync (#766)
Problem:
- Ghost peers from yesterday appeared on app restart
- Old messages resurfaced after restarting
- Peers running 24+ hours synced stale data to restarting peers

Root causes:
1. GossipSyncManager stored packets indefinitely (no time limit)
2. handleAnnounce/handleMessage had no timestamp validation
3. Relayed announces from other peers could be arbitrarily old

Solution (defense in depth):
- Added 15-minute age window to GossipSyncManager config
- Gossip storage rejects expired packets at ingestion
- Gossip sync responses filter out expired packets
- GCS payload building excludes expired packets
- Periodic cleanup removes expired announcements/messages
- handleAnnounce rejects stale announces before processing
- handleMessage rejects stale broadcast messages before processing

This prevents ghost peers from appearing on restart and ensures
only recent mesh state is synchronized between peers.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-07 00:56:25 +02:00
13b58aeaa9 Swiping to close the sidebar (#678)
* enabled drag gesture to close the sidebar view

* removed extraneous onChanged block

* Fix sidebar swipe gestures to work with ScrollView

Improvements:
- Use simultaneousGesture() instead of gesture() to work alongside ScrollView
- Add horizontal-only detection (width > height * 1.5) to prevent interfering with vertical scrolling
- Add visual feedback during drag with sidebarDragOffset updates
- Add 20pt right edge zone for easier swipe-to-open activation (iOS-native behavior)
- Lower threshold for edge swipe (-50pt instead of -100pt)

Both swipe-to-open and swipe-to-close now work reliably:
- Swipe left from anywhere (or from right edge) to open sidebar
- Swipe right on sidebar to close it
- Vertical scrolling unaffected

* Fix sidebar drag offset direction

Changed offset calculation from:
  showSidebar ? -dragOffset : width - dragOffset
To:
  showSidebar ? dragOffset : width + dragOffset

This fixes the issue where dragging right to close the sidebar would
make it fly to the left side of the screen before closing.

Now the sidebar correctly follows the finger during drag:
- When closing: moves right (toward off-screen)
- When opening: moves left (toward on-screen)

* Optimize sidebar drag performance for smooth 60fps

Performance improvements:
- Remove .animation() modifier that was conflicting with drag updates
- Use Transaction with disablesAnimations during drag for instant updates
- Throttle state updates to only fire when offset changes >2pt
- Always render sidebar (avoid conditional view creation overhead)
- Only animate on gesture end, not during drag

This eliminates the lag/jank during swipe by ensuring:
1. No animation conflicts during manual drag
2. Direct offset updates follow finger immediately
3. Stable view hierarchy (no conditional rendering)
4. Reduced state update frequency

The sidebar now feels buttery smooth at 60fps.

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-06 23:24:24 +02:00
b5b05977fb Show Bluetooth permission alerts on launch and foreground (#765)
* Fix test suite peer ID collisions

Use unique peer IDs for each test suite to prevent global registry
collisions when Swift Testing runs suites in parallel.

- PrivateChatE2ETests: PRIV_* prefix
- PublicChatE2ETests: PUB_* prefix
- Update all peer ID references to use actual instance IDs

This fixes the race condition where simplePublicMessage() was receiving
duplicate deliveries due to registry contamination.

* Add Bluetooth permission & state alerts on launch and foreground

Wire up existing Bluetooth alert infrastructure to show notifications
when Bluetooth is off, unauthorized, or unsupported.

Changes:
- Add didUpdateBluetoothState() to BitchatDelegate protocol
- BLEService now notifies delegate when Bluetooth state changes
- ChatViewModel implements delegate method to show alerts
- Check Bluetooth state on app launch (after 100ms delay)
- Check Bluetooth state when app comes to foreground
- Add getCurrentBluetoothState() method to BLEService

The UI alert already existed but wasn't wired up. Now users will see
appropriate alerts for:
- Bluetooth turned off
- Bluetooth permission denied
- Bluetooth unsupported on device

Alert includes a button to open Settings on iOS.

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-06 22:47:11 +02:00
af6703cf24 Fix test suite peer ID collisions (#764)
Use unique peer IDs for each test suite to prevent global registry
collisions when Swift Testing runs suites in parallel.

- PrivateChatE2ETests: PRIV_* prefix
- PublicChatE2ETests: PUB_* prefix
- Update all peer ID references to use actual instance IDs

This fixes the race condition where simplePublicMessage() was receiving
duplicate deliveries due to registry contamination.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-06 22:46:45 +02:00
eb13eec4c5 Swift Testing (#748)
* Swift Testing: `PrivateChatE2ETests` + minor refactor

* Swift Testing: `PublicChatE2ETests`

* Swift Testing: `FragmentationTests`

* Fix MockBLEService init to accept PeerID and remove _testRegister call

* Add peerID property to MockBLEService and fix ttlDecrement test

* Remove duplicate peerID property and fix type comparisons

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-06 20:55:52 +02:00
IslamandGitHub 81c90b2924 Extract TextMessageView into a separate file (#759)
* Extract `TextMessageView` into a separate file

* Remove dead code
2025-10-06 17:42:06 +02:00
IslamandGitHub 8c368233c4 Extract and simplify PaymentChipView (#758)
* `cashuToken` -> `cashuLinks` + minor refaactor

* Extract and simplify `PaymentChipView`
2025-10-06 17:21:32 +02:00
IslamandGitHub 13ebaad42f PeerID 17/n: PeripheralState + centralToPeerID (#756)
* PeerID 15/n: Bitchat Message & Packet accept in init

* PeerID 16/n: BitchatPeer / PeerInfo / TransportPeer

* PeerID 17/n: `PeripheralState` + `centralToPeerID`
2025-10-06 17:19:43 +02:00
IslamandGitHub 10e3f574ab PeerID 16/n: BitchatPeer / PeerInfo / TransportPeer (#755)
* PeerID 15/n: Bitchat Message & Packet accept in init

* PeerID 16/n: BitchatPeer / PeerInfo / TransportPeer
2025-10-06 17:17:57 +02:00
IslamandGitHub 5f44c56a90 PeerID 15/n: Bitchat Message & Packet accept in init (#754) 2025-10-06 17:16:11 +02:00
IslamandGitHub 01ec4573f8 Extract DeliveryStatusView into a separate file (#757) 2025-10-06 11:20:39 +02:00
IslamandGitHub f86ae5e2ea PeerID 14/n: Transport and its dependents (#753)
* Rearrange `Transport`’s properties and functions

* `NostrTransport`: group Transport-related code together

* `BLEService`: group Transport-related code together

* Extract `NotificationStreamAssembler` into a file

* Move private functions to a dedicated extension

* PeerID 14/n: `Transport` and its dependents
2025-10-06 11:02:20 +02:00
IslamandGitHub 2673d28686 PeerID 12/n: GossipSyncManager (#751)
* Noise types use PeerID

* Fix tests

* Extract `NoiseSessionManager` into a separate file

* Extract `NoiseSessionState` into a separate file

* Remove `failed` state from `NoiseSessionState`

* Extract `NoiseSessionError` into a separate file

* PeerID 12/n: `GossipSyncManager`
2025-10-05 15:53:57 +02:00
IslamandGitHub 03c357f048 PeerID 11/n: Noise types use PeerID + create separate files (#750)
* Noise types use PeerID

* Fix tests

* Extract `NoiseSessionManager` into a separate file

* Extract `NoiseSessionState` into a separate file

* Remove `failed` state from `NoiseSessionState`

* Extract `NoiseSessionError` into a separate file
2025-10-05 15:51:06 +02:00
IslamandGitHub 64f91bb1d6 PeerID 10/n: MessageRouter (#749)
* PeerID 9/n: `NoiseEncryptionService`

* PeerID 10/n: `MessageRouter`
2025-10-05 12:43:33 +02:00
IslamandGitHub 9ecda048e7 PeerID 9/n: NoiseEncryptionService (#747) 2025-10-05 12:39:16 +02:00
IslamandGitHub bdc31d3be3 Don’t auto-register mock BLE services on creation (#746) 2025-10-05 12:36:35 +02:00
GitHub Action 6846b19d83 Automated update of relay data - Sun Oct 5 06:04:21 UTC 2025 2025-10-05 06:04:21 +00:00
IslamandGitHub 524a7e916b PeerID 8/n: NoiseRateLimiter & FavoritesPersistenceService (#745) 2025-10-03 11:32:13 +02:00
IslamandGitHub f2473f857b PeerID 7/n: Unify PeerIDUtils & PeerIDResolver (#744) 2025-10-02 13:41:47 +02:00
IslamandGitHub 8cfee095d3 PeerID 6/n: Unifiy validation (#743) 2025-10-02 13:36:49 +02:00
IslamandGitHub e4ec2ef3fe PeerID 5/n: Ephemeral and Secure Identities (#742) 2025-10-02 13:29:48 +02:00
IslamandGitHub bd11940151 Injectable UserDefaults to fix race condition in tests (#741) 2025-10-02 12:59:48 +02:00
IslamandGitHub 90273cee2d PeerID 4/n: BitchatMessage.senderPeerID + String equality (#739) 2025-10-02 12:57:17 +02:00
jack faae625e53 Add shared macOS scheme to match iOS scheme 2025-10-02 11:59:46 +02:00
3a35b3acc2 Modularization: Extract Tor into a separate module (#602)
* Extract Tor into a separate module

* Add Tor package as a dependency for iOS & macOS targets

* Move `tor-nolzma.xcframework` inside Tor

* Remove `libz` from Frameworks as its linked in Tor

* Remove stray `.gitkeep` from macOS target membership

* Fix missing import and access control for modularized Tor

- Add import Tor to NetworkActivationService
- Make TorManager.shutdownCompletely() public for external access

* Fix tor-nolzma.xcframework structure for Xcode builds

- Add missing Info.plist files to all framework slices
- Restructure macOS framework to use deep bundle format (Versions/)
- Keep iOS frameworks as shallow bundles (standard for iOS)

This fixes the Xcode build errors while maintaining SPM compatibility.

* Remove stale xcframework references from Xcode project

Xcode cleaned up old direct references to tor-nolzma.xcframework
since it's now managed internally by the Tor Swift package.

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-02 11:53:34 +02:00
IslamandGitHub 787386c66d Shared schemes (#738)
* Update .gitignore to not ignored shared settings

`xcshareddata/` should be added to the repo to sync the scheme settings (like running parallel tests or turning on code coverage…)

* Add `bitchat (iOS)` shared scheme

* Parallelized and randomized test execution

* Gather code coverage for `bitchat_iOS` target
2025-10-01 14:49:19 +02:00
IslamandGitHub aeec15f054 Temporarily disable broken tests to catch actual issues (#740) 2025-10-01 14:48:39 +02:00
IslamandGitHub f004f3e7ea PeerID 3/n: Remove dead code (#706)
* PeerID 2/n: Count and hex conversion done with `bare`

* ChatVM: remove dead code `registerPeerPublicKey`

* ChatVM: Remove dead `handlePeerFavoritedUs`

* ChatVM: remove dead functions

* PrivateChatManager: Remove dead code

* BLEService: Remove dead code
2025-10-01 13:38:28 +02:00
IslamandGitHub 8d938e8518 PeerID 2/n: Count and hex conversion done with bare (#705) 2025-10-01 13:33:39 +02:00
jackandGitHub ea72212f66 Prune unused validation helpers (#701) 2025-10-01 13:29:09 +02:00
IslamandGitHub 3183703a63 Fix broken build + uncover localization tests (#702)
* Move LocalizationCatalogTests out of Localization/

SPM is treating all the files under Localization as a resource as per the Package.swift, hence it’s not even building it

* Use a class object vs struct to fix build issue

* Explicitly check that the output is not a l10n key

* Remove skipped test
2025-10-01 13:16:59 +02:00
jack 2ffa709cca Bump version to 1.4.4 2025-09-30 13:14:26 +02:00
IslamandGitHub bf712a0610 Refactor peerID - 1/n: Add PeerID + Tests (#688)
* Unify SHA256 hash and hex usages

* Refactor `peerID` - 1/n: Add `PeerID` + Tests
2025-09-30 12:49:36 +02:00
IslamandGitHub 78fb3f1bf6 Unify SHA256 hash and hex usages (#687) 2025-09-30 12:47:16 +02:00
Jonathan BoiceandGitHub f5af00be88 test(localization): squash divergent history to restore clean commit (#695)
- Replace 474/474 sync divergence with a single descriptive commit
- Keep only intended localization test improvements vs main
- Ensure readable history for code review and future merges
2025-09-30 12:45:49 +02:00
jackandGitHub b2214e30f5 Remove unused favorites and notification helpers (#693) 2025-09-30 12:44:46 +02:00
jackandGitHub 85063e9359 Optimize private chat deduplication (#694) 2025-09-30 12:37:48 +02:00
Jonathan BoiceandGitHub f67f728d79 Fix broke tests with LocationNotesManagerTests to expect localization key (#699)
* fix(test): update LocationNotesManagerTests to expect localization key

The tests were failing because in the test environment, String(localized:) returns
the localization key instead of the actual localized value. Updated the assertions
to expect 'location_notes.error.no_relays' instead of the English translation
'no geo relays available near this location. try again soon.'

* Fix LocationNotesManager test assertions for localization bundle

- Update test assertions to use String(localized:) to match manager behavior in test environment
- Fixes issue where tests expected localized text but manager returns raw keys in SPM test environment
- Both manager and tests now consistently handle localization bundle differences
- Resolves P1 issue: Keep LocationNotes error messages localized
- All 124 tests now pass after clean build

* SPM: process bitchat/Localizable.xcstrings in main target to fix CLI warning; keep tests' Localization resource.
2025-09-30 12:32:53 +02:00
b873b19104 Add new languages (#700)
* Fix establishing encryption typo

* Add Bengali localizations

* Add Hindi localizations

* Add Turkish localizations

* Add Portuguese (Portugal) localizations

* Add widespread localization coverage

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-09-30 12:31:29 +02:00
0f7bcf17ff Refactor: Migrate to Swift String Catalogs (.xcstrings) (#691)
* Automated update of relay data - Sun Sep 21 06:26:33 UTC 2025

* chore(l10n): add empty string catalogs

* chore(l10n): populate string catalogs from legacy resources

* test(l10n): add catalog guardrail suite

* chore(l10n): remove legacy localization files

* fix: Add localization resources to Package.swift targets

- Add .process("Localization") to bitchat target resources
- Add .process("Localization") to bitchatTests target resources
- Resolves Bundle.module resource loading for localization files
- Enables proper localization testing in Swift Package Manager builds

* feat: Add Korean localization and convert to UTF-8 format

Korean Language Support:
- Add complete Korean (ko) localization with 191 strings from PR #686
- Include all app strings: UI, features, system messages, alerts
- Include all share extension strings: status messages, errors
- Verified 100% translation coverage for Korean locale

UTF-8 Format Conversion:
- Convert 23,047 Unicode escape sequences to readable UTF-8 characters
- Transform \u sequences (e.g. \u0625\u063a\u0644\u0627\u0642) to native text (إغلاق)
- Improve maintainability across all 15 supported locales
- Preserve all existing translations while enhancing readability

Locales supported: en, ar, de, es, fr, he, id, it, ja, ko, ne, pt-BR, ru, uk, zh-Hans

* test: Enhance dynamic localization test framework

Dynamic Test Framework:
- Replace hardcoded locale tests with data-driven approach
- Add testLocalizationExpectedValues() for dynamic locale validation
- Add testConfiguredLocalesCompleteness() for coverage verification
- Tests now read configuration from PrimaryLocalizationKeys.json

Expanded Test Coverage:
- Increase from 14 to 33 key validations (135% increase)
- Add critical UI strings: common actions, app info, security, sharing
- Cover 364 total string validations across 15 locales
- Include Korean validation with native Korean expected values

Test Categories Added:
- Common UI: cancel, close, copy actions
- App Info: encryption, offline features, app name
- Bluetooth: permission and settings alerts
- Security: verification badges and actions
- Share Extension: all status and error messages
- Content Actions: accessibility and user actions

Maintains 100% test success rate across all supported locales.

* fix: Convert plural strings to correct xcstrings format

Three plural strings (content.accessibility.people_count,
location_channels.row_title, location_notes.header) were using
incorrect format causing runtime String(format:) errors.

Migrated from stringUnit.variations structure to proper
substitutions.variations format across all 14 languages.

* refactor(l10n): migrate to native String(localized:) APIs

Remove L10n.string wrapper in favor of Swift's native localization APIs.
Migrate 100+ localization call sites to use String(localized:) and String(localized:defaultValue:) with string interpolation.

- Update catalog to use interpolation syntax (\(var)) instead of format specifiers (%@)
- Migrate simple strings to String(localized:)
- Migrate strings with arguments to String(localized:defaultValue:) with interpolation
- Keep format strings for plural substitutions (String(format:locale:))
- Remove bitchat/Utils/Localization.swift

Net result: -407 insertions, +130 deletions across 15 files

* fix(l10n): correct interpolation to use format strings

Interpolation in String(localized:defaultValue:) doesn't work as expected -
the interpolation happens at the call site before localization lookup.

Convert dynamic strings to use String(format:String(localized:),args) pattern:
- Update catalog entries from \(var) syntax to %@ placeholders
- Wrap String(localized:) calls with String(format:locale:) for dynamic values
- Affects 17 strings across 6 files

This fixes UI showing literal "\(geohash)" text instead of actual values.

* chore(l10n): remove invalid catalog entries

Remove auto-extracted literal strings (@, #, ✔︎, @%@, bitchat/) that were
generated without proper localization structure. These caused test
decoding failures.

* fix(l10n): remove unused auto-extracted format string

Remove '%@/%@' key that was auto-extracted by Xcode but never used.
This key only existed in English causing locale parity test failures
across all 13 other languages.

Fixes locale parity tests - all 8 localization tests now pass with
only expected failures (incomplete translations in some locales).

* fix(l10n): copy format string to all locales for 100% completion

Add %@/%@ format string to all 14 non-English locales. Format strings
are locale-independent so using the same value everywhere is correct.

This brings all locales to 100% completion (189/189 strings) to prevent
Xcode from reporting incomplete translations when building.

* fix(l10n): prevent auto-extraction of UI literals

Use Text(verbatim:) for non-localizable UI elements:
- App branding ("bitchat/")
- Symbols (@, #, ✔︎)
- Dynamic usernames (@username)
- Count ratios (reached/total)

This prevents Xcode from auto-extracting these literals into the
String Catalog when building through Xcode GUI, which was causing
locales to show 96% completion instead of 100%.

* chore(l10n): remove auto-extracted UI literal entries

Delete 5 auto-extracted keys from catalog that are now using Text(verbatim:):
- @, #, ✔︎, %@, %@/%@

These were showing as stale/incomplete in Xcode causing 97% completion.
All locales now at 100% (188/188 strings).

* fix(l10n): prevent AttributedString from extracting @ symbol

Use string interpolation "\\(at)" instead of literal "@" in
AttributedString to prevent Xcode from auto-extracting it to the
String Catalog during build.

This was the last string causing locales to show 99% instead of 100%.

* fix(l10n): add %@ as non-translatable key in all locales

Mark %@ as non-translatable and add to all 15 locales with same value.
This prevents Xcode from showing incomplete translations when it
auto-extracts this format specifier during GUI builds.

All locales remain at 100% (189/189 strings).

* refactor: move Localizable.xcstrings to bitchat root

Move bitchat/Localization/Localizable.xcstrings to bitchat/ (after LaunchScreen)
and remove empty Localization directory.

* fix(test): update catalog path in localization tests

Update test paths from bitchat/Localization/Localizable.xcstrings to
bitchat/Localizable.xcstrings after moving the file.

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-09-29 22:31:59 +02:00
GitHub Action da2a12296e Automated update of relay data - Sun Sep 28 06:04:29 UTC 2025 2025-09-28 06:04:30 +00:00
sheepbellandGitHub 56bd100944 Fix a typo in base Localizable.strings (#685) 2025-09-27 11:27:26 +02:00
IslamandGitHub eb5bc96a3c Unify the usages of splitSuffix() (#683) 2025-09-26 11:13:18 +02:00
IslamandGitHub d01538a2ea Fix Localizations (#684)
* Update all `NSLocalizedString` with `L10n.string`

+ combine with `L10n.format`

* Handle Localizations + Swift Package
2025-09-26 11:09:10 +02:00
IslamandGitHub 9abfab3248 Comment out broken tests (#675) 2025-09-25 19:34:32 +02:00
IslamandGitHub 0ae09f73d8 Fix tests that were broken after localization integration (#677) 2025-09-25 19:34:08 +02:00
IslamandGitHub 56dd12f3b1 Add default localization to fix CI builds (#674) 2025-09-25 14:14:25 +02:00
f5caa1751a Add base localization infrastructure and externalize strings (#670)
* Add base localization infrastructure and externalize strings

* Add Spanish localization scaffolding with translations

* Add machine translations for expanded locales

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-09-24 15:12:42 +02:00
jack de906cb97c Refresh typography and channel sheet styling 2025-09-24 12:32:11 +02:00
Mattia MarcheseandGitHub a41ec65f58 Include mermaid diagrams for packet structures (#666)
Added mermaid diagrams for BitchatPacket and BitchatMessage structures.
2025-09-24 12:18:25 +02:00
1fd2da18f5 Improve BLE relay reliability (#665)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-09-23 21:09:22 +02:00
c49a1b264e Support Dynamic Type across chat surfaces (#664)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-09-23 19:56:16 +02:00
leoperegrinoandGitHub 10c0391eaf enforce https in noiseprotocol.org url (#661) 2025-09-23 14:08:48 +02:00
3a94b57341 Fix BLE stream crashes and gossip sync races (#663)
* Handle long BLE packets safely

* Keep BLE stream aligned after partial drops

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-09-23 14:06:13 +02:00
f8f780d2d6 Refine location notes UI and align sheet layouts (#660)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-09-21 14:56:20 +02:00
jackandGitHub c837afb818 Remove unused handshake coordinator and identity placeholders (#656) 2025-09-21 13:20:21 +02:00
IslamandGitHub 47ef82f01a Refactor 2/n: ChatViewModel's Geohash Subscription (#635)
* Extract `processNostrMessage` into a function

* `updateChannelActivityTimeThenSend` function

* Break down / flatten `beginGeohashSampling`

* Extract `subscribeNostrEvent` into a function

* Break down / flatten `resubscribeCurrentGeohash`
2025-09-21 12:46:28 +02:00
jack d1e5ce21a7 Enable default relays when location permission granted 2025-09-21 12:43:27 +02:00
GitHub Action f2c1bb2131 Automated update of relay data - Sun Sep 21 06:04:25 UTC 2025 2025-09-21 06:04:25 +00:00
RubensandGitHub 221819b591 fix: crash on shared extension (#621)
* fix: crash on shared extension

* fix: global(qos: .default) to global()

* fix: removed weak self
2025-09-17 08:03:07 -07:00
RubensandGitHub 4a21ab0531 chore: debug icon (#634)
* chore: add debug icon to make it easier to identify debug builds on developers' devices

* chore: add new AppIcon assets with 1024x1024
2025-09-17 08:00:06 -07:00
jack 50ae8da5f9 Bump marketing version to 1.4.2 2025-09-16 08:16:21 -07:00
54bb812469 Gate relays on mutual favorites and add Tor toggle (#631)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-09-16 08:12:51 -07:00
IslamandGitHub 482fca81ef Single source of truth for Marketing Version (#627) 2025-09-16 06:44:30 -07:00
IslamandGitHub b2e7d2d26e Set macOS App Category as Social Media as well (#628) 2025-09-16 06:43:23 -07:00
IslamandGitHub 6a2832d22b Prevent Github Languages stats skewing (#630) 2025-09-16 06:42:36 -07:00
jack 56e7324069 Improve Tor dormant resume and restart flow 2025-09-15 23:08:29 +02:00
1733dda6cd Ignore self when presenting message actions (#625)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-09-15 21:58:39 +02:00
00ff5fd31c Refine panic mode to regenerate identities immediately (#624)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-09-15 21:39:39 +02:00
RubensandGitHub f684c452b2 fix: adjust Justfile after removing XcodeGen from the project (#620) 2025-09-15 20:59:56 +02:00
9cbdb0a764 Gate Tor/Nostr start behind permissions or mutual favorites; add clean shutdown + robust gating (#619)
- Add NetworkActivationService to permit Tor/Nostr only when location is authorized OR at least one mutual favorite exists.
- Gate TorManager.startIfNeeded/ensureRunningOnForeground behind a global allowAutoStart flag.
- Always stop Tor on background for deterministic restarts; rebuild sessions on foreground when allowed.
- NostrRelayManager respects the gate in connect/ensureConnections/subscribe/send/connectToRelay and skips reconnection when disallowed.
- Symmetric shutdown when conditions become disallowed: disconnect relays and stop Tor.
- Fix double-start by avoiding restart if Tor is already ready; prevent background thrash.
- Improve UX: post "starting tor…" via TorWillStart, and "tor started…" on initial ready; keep existing restart messages.

Rationale: Avoid starting Tor/relays when the user has no location permission and no mutual favorites, and ensure a clean, predictable lifecycle (no stale sockets, no double starts).

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-09-15 16:46:49 +02:00
IslamandGitHub d1682db79b Refactor 1/n: ChatViewModel's Message Sending section (#613)
* Extract BitchatMessage into a separate file

* Convert `fromBinaryPayload` to `convenience init?`

* Extract message dedup into an extension

* Remove dead `formatMessageContent`

* Minor refactor of timestamp and username formatting

* Remove dead `getSenderColor`

* Extract MessagePadding into a separate file

* Extract BitchatPacket into a separate file

* Extract ReadReceipt into a separate file

* Extract NoisePayload into a separate file

* Remove unnecessary import

* Extract peer-seed color calculation out

* Extract `handleDeliveredReadReceipt` to a new function

* Extract `handlePrivateMessage` to a new function

* Separate `delivered` and `readReceipt` functions

* Extract `handleGiftWrap` into a function

* Extract `subscribeToGeoChat` into a function

* Minor cleanup

* Extract `handleNostrEvent` into a function

* Minor cleanup

* Create `sendGeohash` function + minor cleanup

* Extract sending geohash dm into a function

* Check for blocks before trying to send a DM
2025-09-15 15:29:47 +02:00
IslamandGitHub 6a6504c6f2 Refactor: Extract types from BitchatProtocol (#611)
* Extract BitchatMessage into a separate file

* Convert `fromBinaryPayload` to `convenience init?`

* Extract message dedup into an extension

* Remove dead `formatMessageContent`

* Minor refactor of timestamp and username formatting

* Remove dead `getSenderColor`

* Extract MessagePadding into a separate file

* Extract BitchatPacket into a separate file

* Extract ReadReceipt into a separate file

* Extract NoisePayload into a separate file

* Remove unnecessary import
2025-09-15 15:21:03 +02:00
IslamandGitHub ea8d51a36b Refactor: BitchatMessage (#610)
* Extract BitchatMessage into a separate file

* Convert `fromBinaryPayload` to `convenience init?`

* Extract message dedup into an extension

* Remove dead `formatMessageContent`

* Minor refactor of timestamp and username formatting

* Remove dead `getSenderColor`
2025-09-15 15:00:12 +02:00
IslamandGitHub 347ce5ece4 Modularization: Extract SecureLogger into a separate module (#600)
* Extract SecureLogger into a separate module

* Add BitLogger package as a dependency for iOS & macOS targets
2025-09-15 14:45:58 +02:00
IslamandGitHub 7c4bde59b9 Xcode Configuration files: .xcconfigs + Remove xcodegen (#608)
* Create configs files with basic settings populated

* Add Configs and set the global Debug/Release settings

* Update build settings to be read from the configs

* Remove `xcodegen`’s `project.yml`

* Configurable and dynamic bundle and group ids

* Simplified local development with custom Team IDs
2025-09-15 13:58:49 +02:00
2ac01db9c4 SYNC_REQUEST 2 (#616)
* wip

* woohooo

* Plumtree gossip: don't subset REQUEST_SYNC fanout; make RequestSyncPacket.encode use const

* bloom -> gcs [wip]

* fix build

* fix broadcast

* prune old messages too

* faster sync

* prune better

* adjust parameters

* fix(sync): make cap a constant in GCSFilter.buildFilter to silence 'never mutated' warning

* fix(mesh): surface self-origin public messages recovered via sync; only ignore self when TTL != 0 in handleMessage

* sync: allow self messages via GCS restore and relax TTL==0 acceptance\n- Bypass dedup for self TTL==0 packets in handleReceivedPacket\n- Accept self TTL==0 in handleMessage and set nickname\n- Accept unknown senders for TTL==0 with anon# prefix to restore history

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-09-15 13:57:09 +02:00
jack 42cdc4c123 Xcode: dedupe libz.tbd reference in project.pbxproj 2025-09-14 15:46:06 +02:00
IslamandGitHub fb94e799a5 Refactor .xcodeproj with buildable folders (#599)
* Remove unused LocationNotesSheet.swift

* Add README.md to bitchatTest group to mirror the folder

* Convert bitchat, Tests, ShareExtension to folders

* Update Project Format to Xcode 16.3 (latest)
2025-09-14 15:37:45 +02:00
IslamandGitHub 04671caeb8 Remove unused LocationNotesSheet.swift (#606) 2025-09-14 14:38:34 +02:00
GitHub Action a485335649 Automated update of relay data - Sun Sep 14 06:04:21 UTC 2025 2025-09-14 06:04:21 +00:00
de2b5ed142 Fix/various UI fixes (#605)
* UI: replace textual 'close' with X icon\n\n- AppInfoView (iOS): use xmark icon in nav bar to match Location Notes style.\n- LocationChannelsSheet: use xmark icon for close on iOS/macOS toolbars; add accessibility label.

* Location Notes: prefix usernames with @ and lighten #geohash\n\n- Show @ before usernames in notes list.\n- Split header into '@' and '#geohash' and color the geohash with secondary green for consistency.

* Header spacing: add breathing room between channel badge, notes button, and people count\n\n- Add trailing padding after #mesh/#geohash badge.\n- Add leading padding before notes button and people counter to improve readability.

* Header: nudge #mesh/#geohash badge right with leading padding

* Location Notes + Header polish\n\n- Header: add space in '@ #geohash' and use darker green for geohash.\n- Notes list: render '@name#abcd' with darker green for #abcd to match chat.\n- Header: move geochat bookmark icon after #geohash badge with consistent spacing.

* Location Notes: @name regular green, #abcd darker; nudge #hash\n\n- Render '@' and base name in regular green, suffix '#abcd' in darker green.\n- Add extra left padding before '#geohash' in notes header.\n- Increase leading padding for channel badge to push #mesh/#geohash further right.

* Fix notes icon color: subscribe/count at block-level geohash\n\n- Use block (precision 7) geohash for notes: when opening sheet, on channel changes, and when subscribing the counter.\n- Aligns with LocationNotesManager which publishes at street-level geohash, allowing the counter to detect notes and turn icon blue.

* Header spacing: move #mesh/#geohash closer to notes/bookmark\n\n- Reduce trailing padding on channel badge and leading padding on notes/bookmark to cluster them together.\n- Keeps larger gap before people count for readability.

* Notes: standardize on building-level (8 chars) for publish/read\n\n- Use .building geohash when opening notes, reacting to channel updates, and subscribing the counter.\n- Update comments to reflect building-level scope.

* Location Channels sheet: use black sheet background like other sheets\n\n- Add backgroundColor and apply to container and list.\n- Hide list default background with scrollContentBackground(.hidden).

* Notes icon: use green when notes exist (matches app green)

* Location Channels: boxed 'bookmarked' section; keep 'remove location access' outside box\n\n- Wrap bookmarked list in a rounded, subtle grey box within the list.\n- Ensure the 'remove location access' button is not inside the box and clears list row background.

* Notes counter UX: avoid grey flicker when closing sheet\n\n- Preserve last count during resubscribe to prevent brief 0 state.\n- Keep existing subscription when building geohash temporarily unavailable; only cancel if none or permission revoked.

* Notes counter: unsubscribe without clearing count on resubscribe\n\n- Avoid calling cancel() in subscribe; just unsubscribe old sub to prevent wiping count to 0.\n- Prevents green→grey flicker after closing sheet or location updates.

* Notes icon: use sheet count until counter finishes initial load; don’t zero on sheet close\n\n- Compute hasNotes using max(count, sheetCount) while initialLoadComplete is false.\n- Remove sheetNotesCount reset on sheet disappear to avoid transient grey.

* Location Notes header: remove extra gap before #geohash (drop leading padding)

* Notes sheet: color #abcd suffix as darker green via opacity (match chat)

* Notes sheet: show timestamp in brackets; drop #abcd from @name

* chore: commit remaining local changes

* Header: move notes + bookmark to left of #mesh/#geohash

* Header spacing: tighten gap between #mesh/#geohash and peer count

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-09-13 23:05:19 +02:00
jack 57cc9028cb Fix EXC_GUARD crash: avoid closing Tor-owned fd on tor exit\n\nCTorHost: stop calling close(owning_fd_tor) in tor_thread_main. Tor already\ncloses its end; closing a reused guarded fd can trigger EXC_GUARD on iOS 18.\nWe now treat it as Tor-owned and just invalidate our reference. 2025-09-13 21:09:23 +02:00
b0a50c663f Tor (Simulator): add iOS simulator slice to tor-nolzma.xcframework; wire Xcode project; basic docs (#604)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-09-13 20:48:57 +02:00
jack 0a98844c1a Xcode: update Tor C group and references after moving CTorHost.c into Services/Tor/C and adding include/.gitkeep 2025-09-13 15:06:52 +02:00
IslamandGitHub efb9a5070d SPM Test target + Github Action to build and test (#596)
* SPM Test target + Github Action to build and test

Because the tests are XCTests `swift test` runs them first and then runs another batch of empty tests which results in "0 tests" at the end of the report - https://github.com/swiftlang/swift-package-manager/issues/8529#issuecomment-2815711345

* Fix dependency and library issues + handle mixed languages

`include` folder has to be next to the `*.c` file for it to work
2025-09-13 15:01:30 +02:00
2870acdcc7 Location Notes (kind 1) at Building Precision + Live Updates (#598)
* Location notes: Matrix loader with 1–3s minimum display; allow multiple notes; wire notes UI from mesh toolbar

* Location notes: prevent duplicate loading/subscriptions (StateObject manager, geohash captured at open, guard subscribe)

* Location notes: fix duplicate  state redeclaration in ContentView

* Location notes: render note bodies with monospaced font in notes list

* Location notes: add close (x) button in sheet header (upper-right) using @Environment(\.dismiss)

* Location notes: acquire geohash on open (force refresh) and show Matrix loader until block-level geohash resolves; add LocationNotesSheet wrapper

* Location notes: inline sheet wrapper to avoid missing file in target; force location refresh and show Matrix loader until block geohash resolves

* Location notes: remove Matrix animation; use simple spinner when acquiring location; simplify notes view layout

* Location notes: remove per-note relative timestamp from sheet (no seconds display)

* Location notes: fix intermittent first load by ensuring resubscribe after geohash change, removing over-eager subscribe guard, and widening note fetch window (no since filter)

* Location notes: add background counter service and show count next to notes icon on mesh; auto-subscribe to current block geohash

* Location notes: move notes icon to the right of #mesh badge in toolbar

* Location notes: restore timestamps in notes list; remove loading state from manager and sheet (no spinner/animation)

* Location notes: drop 'teleport' tag from kind-1 events; simplify note builder API and usage

* Location notes: show timestamp as relative within 7 days, else absolute date (MMM d or MMM d, y); keep monospaced styling

* Location notes: append 'ago' to relative timestamps (within 7 days)

* Switch location notes and UI helpers to building-level geohash (precision 8): add GeohashChannelLevel.building, map length 8, use building for notes selection and counter, add building name mapping

* Location notes: change notes toolbar icon to SF Symbol 'long.text.page.and.pencil'

* Revert notes geohash selection back to block-level (precision 7); keep building level available but unused; update counter and sheet accordingly

* Location notes: show block name (from reverse geocode) in header instead of 'street-level notes'

* Nostr: add EOSE handling with callback support for subscriptions; wire to LocationNotesManager/Counter (initialLoadComplete). Remove 'building' level from channel enum and geocoder mapping; geohash list shows block/neighborhood/city/province/region only

* Fix Swift 6 isolation: hop to @MainActor inside Timer callback for EOSE tracker mutations

* Notes counter: show 0 by default; subscribe at building-level (precision 8) for notes and counter; keep building hidden in channel list

* Notes header: show building name when available (fallback to block name)

* Notes: subscribe counter to building + parent block (merge results); hide notes icon unless location is authorized; replace 10s timer with live location updates while sheet open

* Notes counter: subscribe only to building geohash (precision 8) so count reflects current 8-cell; auto-begin live location refresh on mesh (and permission) to update as you walk

* Notes header: show count in parentheses; icon shows blue if any notes, grey if none; only start live location updates while sheet is open; reduce nickname width; set live distance filter to 10m

* Notes header: show count as '(N note/notes)' with non-bold, secondary styling next to title

* Notes header: move count before title as '<N> note(s) @ #gh'; remove parentheses; keep count non-bold

* Notes header: bold count text; ensure sheet updates when building geohash changes by calling manager.setGeohash on geohash change

* Notes sheet: add light haptic feedback when building geohash changes (iOS only)

* Notes icon: force a one-shot location refresh before (re)subscribing the counter so icon turns blue immediately on current 8-cell

* Notes subscribe: fallback to default relays when GeoRelayDirectory has no entries (pass nil relayUrls) so counter/icon turn blue and sheet loads even before georelay fetch

* Notes icon: turn blue immediately when sheet shows notes by passing count up from sheet (closure) and OR-ing with counter; reset on sheet close

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-09-13 14:55:04 +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
0ce68bc762 Perf/optimizations (#563)
* Perf: reduce hot‑path overhead (logger autoclosure, zero‑copy BinaryProtocol.decode, prealloc encoders)

* Compression: revert to zlib per request (compatibility)

* Nostr: parse inbound messages off-main, then update state on main; BLE: debounce peer snapshot publishing to reduce churn

* Fix: Swift 6 concurrency - avoid capturing self in detached tasks; deliver parsed Nostr messages via MainActor singleton

* Fix: move ParsedInbound + parseInboundMessage to file scope (non-isolated) to satisfy Swift 6; update detached tasks to call free function

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-09-06 12:47:32 +02:00
5273f13512 Fix critical routing and buffer issues\n\n- Preserve unsent items in MessageRouter outbox (no data loss)\n- Drain and bound Nostr send queue; flush on relay connect\n- Cap BLE pending write buffers per peripheral to avoid OOM\n- Enable Nostr fallback for short peer IDs via favorites mapping\n- Route favorite notifications via MessageRouter (mesh/Nostr) (#559)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-09-04 21:26:58 +02:00
e79bcf531b Feature/screenshot privacy warning (#541)
* Screenshot privacy: warn on location sheet; gate screenshot broadcasts to chat only\n\n- Track sheet presentation in ChatViewModel\n- Show privacy alert when screenshot taken on location channels\n- Ignore screenshots for App Info and Location sheet for broadcast\n- ContentView wires sheet onAppear/onDisappear and presents alert\n- Fix location sheet subtitle wrapping: render as single Text to avoid orphaned bullets

* Fix duplicate messages after channel switch\n\n- Dedup on per-geohash append (skip if ID exists)\n- Dedup and sort timeline by timestamp when switching into a geohash\n- Keep content/layout unchanged; add lightweight debug logs for empty rows

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-28 14:37:17 +02:00
2469 changed files with 419543 additions and 14093 deletions
+20
View File
@@ -0,0 +1,20 @@
# Prevent Github Languages stats skewing:
# Binaries and assets
**/*.xcframework/** linguist-vendored
**/*.xcassets/** linguist-vendored
# Generated files
**/*.pbxproj linguist-generated
**/*.storyboard linguist-generated
Package.resolved linguist-generated
# Downloaded CSVs
relays/online_relays_gps.csv linguist-vendored
# Docs
**/*.md linguist-documentation
# Configs
Configs/*.xcconfig linguist-documentation
**/*.plist linguist-documentation
+71
View File
@@ -0,0 +1,71 @@
name: Fetch GeoRelays Data
on:
schedule:
- cron: '0 6 * * 0'
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
update-relay-data:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Fetch GeoRelays
run: |
wget -q https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
mv nostr_relays.csv ./relays/online_relays_gps.csv
- name: Configure git
run: |
git config user.email "action@github.com"
git config user.name "GitHub Action"
- name: Create update branch if changes
id: create_branch
run: |
# exit early if no changes
if git diff --quiet --relays/online_relays_gps.csv; then
echo "changed=false" >> $GITHUB_OUTPUT
exit 0
fi
# branch name with timestamp
BRANCH="update-georelays-$(date -u +%Y%m%dT%H%M%SZ)"
git checkout -b "$BRANCH"
git add relays/online_relays_gps.csv
git commit -m "Automated update of relay data - $(date -u --rfc-3339=seconds)"
echo "changed=true" >> $GITHUB_OUTPUT
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
- name: Push branch
if: steps.create_branch.outputs.changed == 'true'
run: |
git push --set-upstream origin "${{ steps.create_branch.outputs.branch }}"
- name: Create pull request
if: steps.create_branch.outputs.changed == 'true'
uses: peter-evans/create-pull-request@v5
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: Automated update of relay data
branch: ${{ steps.create_branch.outputs.branch }}
base: main
title: Automated update of relay data
body: |
This PR was created automatically by the scheduled workflow. It updates relays/online_relays_gps.csv from the GeoRelays source.
labels: automated, georelays
- name: No changes
if: steps.create_branch.outputs.changed != 'true'
run: echo "No changes to relays/online_relays_gps.csv"
+27
View File
@@ -0,0 +1,27 @@
name: Build & Test
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
test:
name: Run Swift Tests
runs-on: macos-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Set up Swift
uses: swift-actions/setup-swift@v2
- name: Build the package
run: swift build
- name: Run Tests
run: swift test --parallel
+8 -4
View File
@@ -9,9 +9,6 @@ plans/
CLAUDE.md
AGENTS.md
## User settings
xcuserdata/
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
*.xcscmblueprint
*.xccheckout
@@ -57,7 +54,8 @@ iOSInjectionProject/
## Xcode project
*.xcodeproj/project.xcworkspace/
*.xcodeproj/xcshareddata/
## Xcode User settings
xcuserdata/
## Python
__pycache__/
@@ -68,6 +66,9 @@ __pycache__/
*.tmp
*.temp
## Cache
.cache/
# Local build results
.Result*/
.Result*.xcresult/
@@ -75,3 +76,6 @@ TestResult.xcresult/
*.xcresult/
build.log
*.log
# Local configs
Local.xcconfig
+3 -3
View File
@@ -37,7 +37,7 @@ This three-message pattern provides:
#### NoiseEncryptionService
The main service managing all Noise operations:
```swift
class NoiseEncryptionService {
final class NoiseEncryptionService {
private let staticIdentityKey: Curve25519.KeyAgreement.PrivateKey
private let sessionManager: NoiseSessionManager
private let channelEncryption = NoiseChannelEncryption()
@@ -47,7 +47,7 @@ class NoiseEncryptionService {
#### NoiseSession
Individual session state for each peer:
```swift
class NoiseSession {
final class NoiseSession {
private var handshakeState: NoiseHandshakeState?
private var sendCipher: NoiseCipherState?
private var receiveCipher: NoiseCipherState?
@@ -58,7 +58,7 @@ class NoiseSession {
#### NoiseSessionManager
Thread-safe session management:
```swift
class NoiseSessionManager {
final class NoiseSessionManager {
private var sessions: [String: NoiseSession] = [:]
private let sessionsQueue = DispatchQueue(label: "noise.sessions", attributes: .concurrent)
}
+4
View File
@@ -0,0 +1,4 @@
#include "Release.xcconfig"
// Optional include of local configs
#include? "Local.xcconfig"
+5
View File
@@ -0,0 +1,5 @@
// Your Apple Developer Team ID - https://stackoverflow.com/a/18727947
DEVELOPMENT_TEAM = ABC123
// Unique bundle id to be able to register and run locally
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.$(DEVELOPMENT_TEAM)
+11
View File
@@ -0,0 +1,11 @@
MARKETING_VERSION = 1.5.0
CURRENT_PROJECT_VERSION = 1
IPHONEOS_DEPLOYMENT_TARGET = 16.0
MACOSX_DEPLOYMENT_TARGET = 13.0
SWIFT_VERSION = 5.0
DEVELOPMENT_TEAM = L3N5LHJD5Y
CODE_SIGN_STYLE = Automatic
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat
+8 -17
View File
@@ -14,16 +14,16 @@ default:
# Check prerequisites
check:
@echo "Checking prerequisites..."
@command -v xcodegen >/dev/null 2>&1 || (echo "❌ XcodeGen not found. Install with: brew install xcodegen" && exit 1)
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ Xcode not found. Install Xcode from App Store" && exit 1)
@security find-identity -v -p codesigning | grep -q "Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install Xcode from App Store" && exit 1)
@xcode-select -p | grep -q "Xcode.app" || (echo "❌ Full Xcode required, not just command line tools. Install from App Store and run:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
@test -d "/Applications/Xcode.app" || (echo "❌ Xcode.app not found in Applications folder. Install from App Store" && exit 1)
@xcodebuild -version >/dev/null 2>&1 || (echo "❌ Xcode not properly configured. Try:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
@security find-identity -v -p codesigning | grep -q "Apple Development\|Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
@echo "✅ All prerequisites met"
# Backup original files
backup:
@echo "Backing up original project configuration..."
@cp project.yml project.yml.backup 2>/dev/null || true
@# Backup other files that get modified by xcodegen
@if [ -f bitchat.xcodeproj/project.pbxproj ]; then cp bitchat.xcodeproj/project.pbxproj bitchat.xcodeproj/project.pbxproj.backup; fi
@if [ -f bitchat/Info.plist ]; then cp bitchat/Info.plist bitchat/Info.plist.backup; fi
@@ -44,13 +44,8 @@ patch-for-macos: backup
@# Move iOS-specific files out of the way temporarily
@if [ -f bitchat/LaunchScreen.storyboard ]; then mv bitchat/LaunchScreen.storyboard bitchat/LaunchScreen.storyboard.ios; fi
# Generate Xcode project with patches
generate: patch-for-macos
@echo "Generating Xcode project..."
@xcodegen generate
# Build the macOS app
build: check generate
build: #check generate
@echo "Building BitChat for macOS..."
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
@@ -75,9 +70,7 @@ clean: restore
# Quick run without cleaning (for development)
dev-run: check
@echo "Quick development build..."
@if [ ! -f project.yml.backup ]; then just patch-for-macos; fi
@xcodegen generate
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat_macOS" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
@find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" -not -path "*/Index.noindex/*" | head -1 | xargs -I {} open "{}"
# Show app info
@@ -106,11 +99,9 @@ nuke:
@echo "🧨 Nuclear clean - removing all build artifacts and backups..."
@rm -rf ~/Library/Developer/Xcode/DerivedData/bitchat-* 2>/dev/null || true
@rm -rf bitchat.xcodeproj 2>/dev/null || true
@rm -f project.yml.backup 2>/dev/null || true
@rm -f project-macos.yml 2>/dev/null || true
@rm -f bitchat.xcodeproj/project.pbxproj.backup 2>/dev/null || true
@rm -f bitchat/Info.plist.backup 2>/dev/null || true
@# Restore iOS-specific files if they were moved
@if [ -f bitchat/LaunchScreen.storyboard.ios ]; then mv bitchat/LaunchScreen.storyboard.ios bitchat/LaunchScreen.storyboard; fi
@git checkout -- project.yml bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo "⚠️ Not a git repo or no changes to restore"
@git checkout bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo "⚠️ Not a git repo or no changes to restore"
@echo "✅ Nuclear clean complete"
+25 -3
View File
@@ -4,6 +4,7 @@ import PackageDescription
let package = Package(
name: "bitchat",
defaultLocalization: "en",
platforms: [
.iOS(.v16),
.macOS(.v13)
@@ -15,13 +16,17 @@ let package = Package(
),
],
dependencies:[
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1"),
.package(path: "localPackages/Tor"),
.package(path: "localPackages/BitLogger"),
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
],
targets: [
.executableTarget(
name: "bitchat",
dependencies: [
.product(name: "P256K", package: "swift-secp256k1")
.product(name: "P256K", package: "swift-secp256k1"),
.product(name: "BitLogger", package: "BitLogger"),
.product(name: "Tor", package: "Tor")
],
path: "bitchat",
exclude: [
@@ -29,8 +34,25 @@ let package = Package(
"Assets.xcassets",
"bitchat.entitlements",
"bitchat-macOS.entitlements",
"LaunchScreen.storyboard"
"LaunchScreen.storyboard",
"ViewModels/Extensions/README.md"
],
resources: [
.process("Localizable.xcstrings")
]
),
.testTarget(
name: "bitchatTests",
dependencies: ["bitchat"],
path: "bitchatTests",
exclude: [
"Info.plist",
"README.md"
],
resources: [
.process("Localization"),
.process("Noise")
]
)
]
)
+18 -31
View File
@@ -9,7 +9,7 @@ A decentralized peer-to-peer messaging app with dual transport architecture: loc
📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622)
> [!WARNING]
> Private messages have not received external security review and may contain vulnerabilities. Do not use for sensitive use cases, and do not rely on its security until it has been reviewed. Now uses the [Noise Protocol](http://www.noiseprotocol.org) for identity and encryption. Public local chat (the main feature) has no security concerns.
> Private messages have not received external security review and may contain vulnerabilities. Do not use for sensitive use cases, and do not rely on its security until it has been reviewed. Now uses the [Noise Protocol](https://www.noiseprotocol.org) for identity and encryption. Public local chat (the main feature) has no security concerns.
## License
@@ -22,7 +22,7 @@ This project is released into the public domain. See the [LICENSE](LICENSE) file
- **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback)
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers
- **Private Message End-to-End Encryption**: [Noise Protocol](http://noiseprotocol.org) for mesh, NIP-17 for Nostr
- **Private Message End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) for mesh, NIP-17 for Nostr
- **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface
- **Universal App**: Native support for iOS and macOS
- **Emergency Wipe**: Triple-tap to instantly clear all data
@@ -94,45 +94,32 @@ For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.m
## Setup
### Option 1: Using XcodeGen (Recommended)
1. Install XcodeGen if you haven't already:
```bash
brew install xcodegen
```
2. Generate the Xcode project:
### Option 1: Using Xcode
```bash
cd bitchat
xcodegen generate
```
3. Open the generated project:
```bash
open bitchat.xcodeproj
```
### Option 2: Using Swift Package Manager
To run on a device there're a few steps to prepare the code:
- Clone the local configs: `cp Configs/Local.xcconfig.example Configs/Local.xcconfig`
- Add your Developer Team ID into the newly created `Configs/Local.xcconfig`
- Bundle ID would be set to `chat.bitchat.<team_id>` (unless you set to something else)
- Entitlements need to be updated manually (TODO: Automate):
- Search and replace `group.chat.bitchat` with `group.<your_bundle_id>` (e.g. `group.chat.bitchat.ABC123`)
1. Open the project in Xcode:
### Option 2: Using `just`
```bash
cd bitchat
open Package.swift
brew install just
```
2. Select your target device and run
### Option 3: Manual Xcode Project
1. Open Xcode and create a new iOS/macOS App
2. Copy all Swift files from the `bitchat` directory into your project
3. Update Info.plist with Bluetooth permissions
4. Set deployment target to iOS 16.0 / macOS 13.0
### Option 4: just
Want to try this on macos: `just run` will set it up and run from source.
Run `just clean` afterwards to restore things to original state for mobile app building and development.
## Localization
- Base app resources live under `bitchat/Localization/Base.lproj/`. Add new copy to `Localizable.strings` and plural rules to `Localizable.stringsdict`.
- Share extension strings are separate in `bitchatShareExtension/Localization/Base.lproj/Localizable.strings`.
- Prefer keys that describe intent (`app_info.features.offline.title`) and reuse existing ones where possible.
- Run `xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGNING_ALLOWED=NO build` to compile-check any localization updates.
+41
View File
@@ -184,6 +184,28 @@ To minimize bandwidth, `BitchatPacket`s are serialized into a compact binary for
**Padding:** All packets are padded to the next standard block size (256, 512, 1024, or 2048 bytes) using a PKCS#7-style scheme to obscure the true message length from network observers.
```mermaid
---
config:
theme: dark
---
---
title: "BitchatPacket"
---
packet
+8: "Version"
+8: "Type"
+8: "TTL"
+64: "Timestamp"
+8: "Flags"
+16: "Payload Length"
+64: "Sender ID"
+64: "Recipient ID (optional)"
+48: "Payload (variable)"
+64: "Signature (optional)"
```
_A representation of the sizes of the fields in `BitchatPacket`_
### 6.2. Application Message Format (`BitchatMessage`)
For packets of type `message`, the payload is a binary-serialized `BitchatMessage` containing the chat content.
@@ -198,6 +220,25 @@ For packets of type `message`, the payload is a binary-serialized `BitchatMessag
| Original Sender | 1 + len (opt)| Nickname of the original sender if the message is a relay. |
| Recipient Nickname | 1 + len (opt)| Nickname of the recipient for private messages. |
```mermaid
---
config:
theme: dark
---
---
title: "BitchatMessage"
---
packet
+8: "Flags"
+64: "Timestamp"
+24: "ID (variable)"
+32: "Sender (variable)"
+32: "Content (variable)"
+32: "Original Sender (variable) (optional)"
+32: "Recipient Nickname (variable) (optional)"
```
_A representation of the sizes of the fields in `BitchatMessage`_
---
## 7. Message Routing and Propagation
+291 -743
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,131 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1640"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "57CA17A36A2532A6CFF367BB"
BuildableName = "bitchatShareExtension.appex"
BlueprintName = "bitchatShareExtension"
ReferencedContainer = "container:bitchat.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
BuildableName = "bitchat.app"
BlueprintName = "bitchat_iOS"
ReferencedContainer = "container:bitchat.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
codeCoverageEnabled = "YES"
onlyGenerateCoverageForSpecifiedTargets = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
BuildableName = "bitchat.app"
BlueprintName = "bitchat_iOS"
ReferencedContainer = "container:bitchat.xcodeproj">
</BuildableReference>
</MacroExpansion>
<CodeCoverageTargets>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
BuildableName = "bitchat.app"
BlueprintName = "bitchat_iOS"
ReferencedContainer = "container:bitchat.xcodeproj">
</BuildableReference>
</CodeCoverageTargets>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES"
testExecutionOrdering = "random">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "6CB97DF2EA57234CB3E563B8"
BuildableName = "bitchatTests_iOS.xctest"
BlueprintName = "bitchatTests_iOS"
ReferencedContainer = "container:bitchat.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
BuildableName = "bitchat.app"
BlueprintName = "bitchat_iOS"
ReferencedContainer = "container:bitchat.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "BITCHAT_LOG_LEVEL"
value = "debug"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
BuildableName = "bitchat.app"
BlueprintName = "bitchat_iOS"
ReferencedContainer = "container:bitchat.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -0,0 +1,105 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1640"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0576A29205865664C0937536"
BuildableName = "bitchat.app"
BlueprintName = "bitchat_macOS"
ReferencedContainer = "container:bitchat.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0576A29205865664C0937536"
BuildableName = "bitchat.app"
BlueprintName = "bitchat_macOS"
ReferencedContainer = "container:bitchat.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "47FF23248747DD7CB666CB91"
BuildableName = "bitchatTests_macOS.xctest"
BlueprintName = "bitchatTests_macOS"
ReferencedContainer = "container:bitchat.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0576A29205865664C0937536"
BuildableName = "bitchat.app"
BlueprintName = "bitchat_macOS"
ReferencedContainer = "container:bitchat.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "BITCHAT_LOG_LEVEL"
value = "debug"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0576A29205865664C0937536"
BuildableName = "bitchat.app"
BlueprintName = "bitchat_macOS"
ReferencedContainer = "container:bitchat.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+2 -104
View File
@@ -1,111 +1,9 @@
{
"images" : [
{
"filename" : "icon_20x20@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "20x20"
},
{
"filename" : "icon_20x20@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "20x20"
},
{
"filename" : "icon_29x29@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "29x29"
},
{
"filename" : "icon_29x29@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "29x29"
},
{
"filename" : "icon_40x40@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "40x40"
},
{
"filename" : "icon_40x40@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "40x40"
},
{
"filename" : "icon_60x60@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "60x60"
},
{
"filename" : "icon_60x60@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "60x60"
},
{
"filename" : "icon_20x20.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "20x20"
},
{
"filename" : "icon_20x20@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "20x20"
},
{
"filename" : "icon_29x29.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "29x29"
},
{
"filename" : "icon_29x29@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "29x29"
},
{
"filename" : "icon_40x40.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "40x40"
},
{
"filename" : "icon_40x40@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "40x40"
},
{
"filename" : "icon_76x76.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "76x76"
},
{
"filename" : "icon_76x76@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "76x76"
},
{
"filename" : "icon_83.5x83.5@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "83.5x83.5"
},
{
"filename" : "icon_1024x1024.png",
"idiom" : "ios-marketing",
"scale" : "1x",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
Binary file not shown.

Before

Width:  |  Height:  |  Size: 378 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 497 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 570 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 401 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 564 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 668 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 497 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 641 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 765 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 765 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 628 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 930 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 976 B

@@ -0,0 +1,36 @@
{
"images" : [
{
"filename" : "image-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+1 -1
View File
@@ -3,4 +3,4 @@
"author" : "xcode",
"version" : 1
}
}
}
+76 -16
View File
@@ -6,20 +6,39 @@
// For more information, see <https://unlicense.org>
//
import Tor
import SwiftUI
import UserNotifications
@main
struct BitchatApp: App {
@StateObject private var chatViewModel = ChatViewModel()
static let bundleID = Bundle.main.bundleIdentifier ?? "chat.bitchat"
static let groupID = "group.\(bundleID)"
@StateObject private var chatViewModel: ChatViewModel
#if os(iOS)
@Environment(\.scenePhase) var scenePhase
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
// Skip the very first .active-triggered Tor restart on cold launch
@State private var didHandleInitialActive: Bool = false
@State private var didEnterBackground: Bool = false
#elseif os(macOS)
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
#endif
private let idBridge = NostrIdentityBridge()
init() {
let keychain = KeychainManager()
let idBridge = self.idBridge
_chatViewModel = StateObject(
wrappedValue: ChatViewModel(
keychain: keychain,
idBridge: idBridge,
identityManager: SecureIdentityStateManager(keychain)
)
)
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
// Warm up georelay directory and refresh if stale (once/day)
GeoRelayDirectory.shared.prefetchIfNeeded()
@@ -34,15 +53,16 @@ struct BitchatApp: App {
// Inject live Noise service into VerificationService to avoid creating new BLE instances
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
let nickname = chatViewModel.nickname
DispatchQueue.global(qos: .utility).async {
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
let npub = try? idBridge.getCurrentNostrIdentity()?.npub
_ = VerificationService.shared.buildMyQRString(nickname: nickname, npub: npub)
}
#if os(iOS)
appDelegate.chatViewModel = chatViewModel
#elseif os(macOS)
appDelegate.chatViewModel = chatViewModel
#endif
// Initialize network activation policy; will start Tor/Nostr only when allowed
NetworkActivationService.shared.start()
// Check for shared content
checkForSharedContent()
}
@@ -54,10 +74,41 @@ struct BitchatApp: App {
switch newPhase {
case .background:
// Keep BLE mesh running in background; BLEService adapts scanning automatically
break
// Always send Tor to dormant on background for a clean restart later.
TorManager.shared.setAppForeground(false)
TorManager.shared.goDormantOnBackground()
// Stop geohash sampling while backgrounded
Task { @MainActor in
chatViewModel.endGeohashSampling()
}
// Proactively disconnect Nostr to avoid spurious socket errors while Tor is down
NostrRelayManager.shared.disconnect()
didEnterBackground = true
case .active:
// Restart services when becoming active
chatViewModel.meshService.startServices()
TorManager.shared.setAppForeground(true)
// On initial cold launch, Tor was just started in onAppear.
// Skip the deterministic restart the first time we become active.
if didHandleInitialActive && didEnterBackground {
if TorManager.shared.isAutoStartAllowed() && !TorManager.shared.isReady {
TorManager.shared.ensureRunningOnForeground()
}
} else {
didHandleInitialActive = true
}
didEnterBackground = false
if TorManager.shared.isAutoStartAllowed() {
Task.detached {
let _ = await TorManager.shared.awaitReady(timeout: 60)
await MainActor.run {
// Rebuild proxied sessions to bind to the live Tor after readiness
TorURLSession.shared.rebuild()
// Reconnect Nostr via fresh sessions; will gate until Tor 100%
NostrRelayManager.shared.resetAllConnections()
}
}
}
checkForSharedContent()
case .inactive:
break
@@ -90,7 +141,7 @@ struct BitchatApp: App {
private func checkForSharedContent() {
// Check app group for shared content from extension
guard let userDefaults = UserDefaults(suiteName: "group.chat.bitchat") else {
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else {
return
}
@@ -131,19 +182,23 @@ struct BitchatApp: App {
}
#if os(iOS)
class AppDelegate: NSObject, UIApplicationDelegate {
final class AppDelegate: NSObject, UIApplicationDelegate {
weak var chatViewModel: ChatViewModel?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
return true
}
func applicationWillTerminate(_ application: UIApplication) {
chatViewModel?.applicationWillTerminate()
}
}
#endif
#if os(macOS)
import AppKit
class MacAppDelegate: NSObject, NSApplicationDelegate {
final class MacAppDelegate: NSObject, NSApplicationDelegate {
weak var chatViewModel: ChatViewModel?
func applicationWillTerminate(_ notification: Notification) {
@@ -156,7 +211,7 @@ class MacAppDelegate: NSObject, NSApplicationDelegate {
}
#endif
class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
static let shared = NotificationDelegate()
weak var chatViewModel: ChatViewModel?
@@ -169,7 +224,7 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
// Get peer ID from userInfo
if let peerID = userInfo["peerID"] as? String {
DispatchQueue.main.async {
self.chatViewModel?.startPrivateChat(with: peerID)
self.chatViewModel?.startPrivateChat(with: PeerID(str: peerID))
}
}
}
@@ -194,10 +249,15 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
// Get peer ID from userInfo
if let peerID = userInfo["peerID"] as? String {
// Don't show notification if the private chat is already open
if chatViewModel?.selectedPrivateChatPeer == peerID {
completionHandler([])
return
// Access main-actor-isolated property via Task
Task { @MainActor in
if self.chatViewModel?.selectedPrivateChatPeer == PeerID(str: peerID) {
completionHandler([])
} else {
completionHandler([.banner, .sound])
}
}
return
}
}
// Suppress geohash activity notification if we're already in that geohash channel
+203
View File
@@ -0,0 +1,203 @@
import Foundation
import ImageIO
import UniformTypeIdentifiers
#if os(iOS)
import UIKit
#else
import AppKit
#endif
enum ImageUtilsError: Error {
case invalidImage
case encodingFailed
}
enum ImageUtils {
private static let compressionQuality: CGFloat = 0.82
private static let targetImageBytes: Int = 45_000
static func processImage(at url: URL, maxDimension: CGFloat = 448) throws -> URL {
// Security H1: Check file size BEFORE reading into memory
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
guard let fileSize = attrs[.size] as? Int else {
throw ImageUtilsError.invalidImage
}
// Allow up to 10MB source images (will be scaled down)
guard fileSize <= 10 * 1024 * 1024 else {
throw ImageUtilsError.invalidImage
}
let data = try Data(contentsOf: url)
#if os(iOS)
guard let image = UIImage(data: data) else { throw ImageUtilsError.invalidImage }
return try processImage(image, maxDimension: maxDimension)
#else
guard let image = NSImage(data: data) else { throw ImageUtilsError.invalidImage }
return try processImage(image, maxDimension: maxDimension)
#endif
}
#if os(iOS)
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448) throws -> URL {
return try autoreleasepool {
// Scale the image first
let scaled = scaledImage(image, maxDimension: maxDimension)
// Get CGImage from UIImage - this is the key to stripping metadata
guard let cgImage = scaled.cgImage else {
throw ImageUtilsError.encodingFailed
}
// Use CGImageDestination to encode without metadata (same as macOS)
var quality = compressionQuality
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
throw ImageUtilsError.encodingFailed
}
// Compress to target size
while jpegData.count > targetImageBytes && quality > 0.3 {
quality -= 0.1
autoreleasepool {
if let next = encodeJPEG(from: cgImage, quality: quality) {
jpegData = next
}
}
}
let outputURL = try makeOutputURL()
try jpegData.write(to: outputURL, options: .atomic)
return outputURL
}
}
private static func scaledImage(_ image: UIImage, maxDimension: CGFloat) -> UIImage {
let size = image.size
let maxSide = max(size.width, size.height)
guard maxSide > maxDimension else { return image }
let scale = maxDimension / maxSide
let newSize = CGSize(width: size.width * scale, height: size.height * scale)
// Draw into a new context to get a clean CGImage without metadata
UIGraphicsBeginImageContextWithOptions(newSize, true, 1.0)
image.draw(in: CGRect(origin: .zero, size: newSize))
let rendered = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return rendered ?? image
}
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
guard let data = CFDataCreateMutable(nil, 0) else {
return nil
}
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
return nil
}
// Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
// By only specifying compression quality and no metadata keys,
// we ensure a clean JPEG with no privacy-leaking information
let options: [CFString: Any] = [
kCGImageDestinationLossyCompressionQuality: quality
]
CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
guard CGImageDestinationFinalize(destination) else {
return nil
}
return data as Data
}
#else
static func processImage(_ image: NSImage, maxDimension: CGFloat = 448) throws -> URL {
return try autoreleasepool {
let scaled = scaledImage(image, maxDimension: maxDimension)
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
throw ImageUtilsError.encodingFailed
}
let width = inputCG.width
let height = inputCG.height
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
guard let context = CGContext(
data: nil,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: 0,
space: colorSpace,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else {
throw ImageUtilsError.encodingFailed
}
context.draw(inputCG, in: CGRect(x: 0, y: 0, width: width, height: height))
guard let cgImage = context.makeImage() else {
throw ImageUtilsError.encodingFailed
}
var quality = compressionQuality
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
throw ImageUtilsError.encodingFailed
}
while jpegData.count > targetImageBytes && quality > 0.3 {
quality -= 0.1
autoreleasepool {
if let next = encodeJPEG(from: cgImage, quality: quality) {
jpegData = next
}
}
}
let outputURL = try makeOutputURL()
try jpegData.write(to: outputURL, options: .atomic)
return outputURL
}
}
private static func scaledImage(_ image: NSImage, maxDimension: CGFloat) -> NSImage {
let size = image.size
let maxSide = max(size.width, size.height)
guard maxSide > maxDimension else { return image }
let scale = maxDimension / maxSide
let newSize = NSSize(width: size.width * scale, height: size.height * scale)
let scaledImage = NSImage(size: newSize)
scaledImage.lockFocus()
image.draw(in: NSRect(origin: .zero, size: newSize),
from: NSRect(origin: .zero, size: size),
operation: .copy,
fraction: 1.0)
scaledImage.unlockFocus()
return scaledImage
}
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
guard let data = CFDataCreateMutable(nil, 0) else {
return nil
}
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
return nil
}
// Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
// By only specifying compression quality and no metadata keys,
// we ensure a clean JPEG with no privacy-leaking information
let options: [CFString: Any] = [
kCGImageDestinationLossyCompressionQuality: quality
]
CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
guard CGImageDestinationFinalize(destination) else {
return nil
}
return data as Data
}
#endif
private static func makeOutputURL() throws -> URL {
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd_HHmmss"
let fileName = "img_\(formatter.string(from: Date())).jpg"
let directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
return directory.appendingPathComponent(fileName)
}
private static func applicationFilesDirectory() throws -> URL {
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
return base.appendingPathComponent("files", isDirectory: true)
}
}
@@ -0,0 +1,193 @@
import Foundation
import AVFoundation
import BitLogger
/// Controls playback for a single voice note and coordinates exclusive playback across the app.
final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlayerDelegate {
@Published private(set) var isPlaying: Bool = false
@Published private(set) var currentTime: TimeInterval = 0
@Published private(set) var duration: TimeInterval = 0
@Published private(set) var progress: Double = 0
private var player: AVAudioPlayer?
private var timer: Timer?
private var url: URL
init(url: URL) {
self.url = url
super.init()
// Don't load anything eagerly - wait until user interaction or view is fully displayed
}
func loadDuration() {
guard duration == 0 else { return }
DispatchQueue.global(qos: .utility).async { [weak self] in
guard let self = self else { return }
do {
let player = try AVAudioPlayer(contentsOf: self.url)
let loadedDuration = player.duration
DispatchQueue.main.async { [weak self] in
guard let self = self, self.duration == 0 else { return }
self.duration = loadedDuration
}
} catch {
SecureLogger.error("Failed to load audio duration: \(error)", category: .session)
}
}
}
deinit {
timer?.invalidate()
}
func replaceURL(_ url: URL) {
guard url != self.url else { return }
stop()
self.url = url
player = nil
duration = 0
// Duration will be loaded on demand when needed
}
func togglePlayback() {
isPlaying ? pause() : play()
}
func play() {
guard ensurePlayerReady() else { return }
VoiceNotePlaybackCoordinator.shared.activate(self)
player?.play()
startTimer()
updateProgress()
isPlaying = true
}
func pause() {
player?.pause()
stopTimer()
updateProgress()
isPlaying = false
}
func stop() {
player?.stop()
player?.currentTime = 0
stopTimer()
updateProgress()
isPlaying = false
VoiceNotePlaybackCoordinator.shared.deactivate(self)
}
func seek(to fraction: Double) {
guard ensurePlayerReady() else { return }
let clamped = max(0, min(1, fraction))
if let player = player {
player.currentTime = clamped * player.duration
if isPlaying {
player.play()
}
updateProgress()
}
}
// MARK: - AVAudioPlayerDelegate
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
// Delegate callback may be on background thread - ensure main thread for UI updates
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.stopTimer()
self.updateProgress()
self.isPlaying = false
VoiceNotePlaybackCoordinator.shared.deactivate(self)
}
}
// MARK: - Private Helpers
private func preparePlayer(for url: URL) {
// Prepare player synchronously (only called when playback is requested)
do {
let player = try AVAudioPlayer(contentsOf: url)
player.delegate = self
player.prepareToPlay()
self.player = player
duration = player.duration
currentTime = player.currentTime
progress = duration > 0 ? currentTime / duration : 0
} catch {
SecureLogger.error("Voice note playback failed for \(url.lastPathComponent): \(error)", category: .session)
player = nil
duration = 0
currentTime = 0
progress = 0
}
}
private func ensurePlayerReady() -> Bool {
if player == nil {
preparePlayer(for: url)
}
#if os(iOS)
let session = AVAudioSession.sharedInstance()
do {
try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers])
try session.setActive(true, options: [])
} catch {
SecureLogger.error("Failed to activate audio session: \(error)", category: .session)
}
#endif
return player != nil
}
private func startTimer() {
if timer != nil { return }
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
self?.updateProgress()
}
if let timer = timer {
RunLoop.main.add(timer, forMode: .common)
}
}
private func stopTimer() {
timer?.invalidate()
timer = nil
}
private func updateProgress() {
guard let player = player else {
currentTime = 0
duration = 0
progress = 0
return
}
currentTime = player.currentTime
duration = player.duration
progress = duration > 0 ? currentTime / duration : 0
}
}
/// Ensures only one voice note plays at a time.
final class VoiceNotePlaybackCoordinator {
static let shared = VoiceNotePlaybackCoordinator()
private weak var activeController: VoiceNotePlaybackController?
private init() {}
func activate(_ controller: VoiceNotePlaybackController) {
if activeController === controller {
return
}
activeController?.pause()
activeController = controller
}
func deactivate(_ controller: VoiceNotePlaybackController) {
if activeController === controller {
activeController = nil
}
}
}
+170
View File
@@ -0,0 +1,170 @@
import Foundation
import AVFoundation
/// Manages audio capture for mesh voice notes with predictable encoding settings.
/// Recording runs on an internal serial queue to avoid AVAudioSession contention.
final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
enum RecorderError: Error {
case microphoneAccessDenied
case recorderInitializationFailed
case recordingInProgress
}
static let shared = VoiceRecorder()
private let queue = DispatchQueue(label: "com.bitchat.voice-recorder")
private let paddingInterval: TimeInterval = 0.5
private let maxRecordingDuration: TimeInterval = 120
private var recorder: AVAudioRecorder?
private var currentURL: URL?
private var stopWorkItem: DispatchWorkItem?
private override init() {
super.init()
}
// MARK: - Permissions
@discardableResult
func requestPermission() async -> Bool {
#if os(iOS)
return await withCheckedContinuation { continuation in
AVAudioSession.sharedInstance().requestRecordPermission { granted in
continuation.resume(returning: granted)
}
}
#elseif os(macOS)
return await withCheckedContinuation { continuation in
AVCaptureDevice.requestAccess(for: .audio) { granted in
continuation.resume(returning: granted)
}
}
#else
return true
#endif
}
// MARK: - Recording Lifecycle
func startRecording() throws -> URL {
try queue.sync {
if recorder?.isRecording == true {
throw RecorderError.recordingInProgress
}
#if os(iOS)
let session = AVAudioSession.sharedInstance()
guard session.recordPermission == .granted else {
throw RecorderError.microphoneAccessDenied
}
try session.setCategory(
.playAndRecord,
mode: .default,
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
)
try session.setActive(true, options: .notifyOthersOnDeactivation)
#endif
#if os(macOS)
guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
throw RecorderError.microphoneAccessDenied
}
#endif
let outputURL = try makeOutputURL()
let settings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 16_000,
AVNumberOfChannelsKey: 1,
AVEncoderBitRateKey: 16_000
]
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
audioRecorder.delegate = self
audioRecorder.isMeteringEnabled = true
audioRecorder.prepareToRecord()
audioRecorder.record(forDuration: maxRecordingDuration)
recorder = audioRecorder
currentURL = outputURL
stopWorkItem?.cancel()
stopWorkItem = nil
return outputURL
}
}
func stopRecording(completion: @escaping (URL?) -> Void) {
queue.async { [weak self] in
guard let self = self, let recorder = self.recorder, recorder.isRecording else {
completion(self?.currentURL)
return
}
let item = DispatchWorkItem { [weak self] in
guard let self = self else { return }
recorder.stop()
self.cleanupSession()
let url = self.currentURL
self.recorder = nil
self.currentURL = url
completion(url)
}
self.stopWorkItem = item
self.queue.asyncAfter(deadline: .now() + self.paddingInterval, execute: item)
}
}
func cancelRecording() {
queue.async { [weak self] in
guard let self = self else { return }
self.stopWorkItem?.cancel()
self.stopWorkItem = nil
if let recorder = self.recorder, recorder.isRecording {
recorder.stop()
}
self.cleanupSession()
if let url = self.currentURL {
try? FileManager.default.removeItem(at: url)
}
self.recorder = nil
self.currentURL = nil
}
}
// MARK: - Metering
func currentAveragePower() -> Float {
queue.sync {
recorder?.updateMeters()
return recorder?.averagePower(forChannel: 0) ?? -160
}
}
// MARK: - Helpers
private func makeOutputURL() throws -> URL {
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd_HHmmss"
let fileName = "voice_\(formatter.string(from: Date())).m4a"
let baseDirectory = try applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true)
try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: nil)
return baseDirectory.appendingPathComponent(fileName)
}
private func applicationFilesDirectory() throws -> URL {
#if os(iOS)
return try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
.appendingPathComponent("files", isDirectory: true)
#else
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
return base.appendingPathComponent("files", isDirectory: true)
#endif
}
private func cleanupSession() {
#if os(iOS)
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
#endif
}
}
+113
View File
@@ -0,0 +1,113 @@
import AVFoundation
import Foundation
import BitLogger
/// Generates and caches downsampled waveforms for audio files so UI rendering is cheap.
final class WaveformCache {
static let shared = WaveformCache()
private let queue = DispatchQueue(label: "com.bitchat.waveform-cache", attributes: .concurrent)
private var cache: [URL: (waveform: [Float], lastAccess: Date)] = [:]
private let maxCacheSize = 20 // Limit cache to prevent unbounded memory growth
private init() {}
func cachedWaveform(for url: URL) -> [Float]? {
queue.sync {
guard let entry = cache[url] else { return nil }
return entry.waveform
}
}
func waveform(for url: URL, bins: Int = 120, completion: @escaping ([Float]) -> Void) {
queue.async { [weak self] in
guard let self = self else { return }
// Check cache (read-only, no update needed on cache hit for performance)
if let entry = self.cache[url] {
DispatchQueue.main.async { completion(entry.waveform) }
return
}
guard let computed = self.computeWaveform(url: url, bins: bins) else {
DispatchQueue.main.async { completion([]) }
return
}
self.queue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
// Evict oldest entry if cache is full
if self.cache.count >= self.maxCacheSize {
if let oldest = self.cache.min(by: { $0.value.lastAccess < $1.value.lastAccess }) {
self.cache.removeValue(forKey: oldest.key)
}
}
self.cache[url] = (computed, Date())
}
DispatchQueue.main.async { completion(computed) }
}
}
func purge(url: URL) {
queue.async(flags: .barrier) { [weak self] in
self?.cache.removeValue(forKey: url)
}
}
func purgeAll() {
queue.async(flags: .barrier) { [weak self] in
self?.cache.removeAll()
}
}
private func computeWaveform(url: URL, bins: Int) -> [Float]? {
guard bins > 0 else { return nil }
// Use autoreleasepool to manage memory from audio buffer allocations
return autoreleasepool {
do {
let audioFile = try AVAudioFile(forReading: url)
let length = Int(audioFile.length)
guard length > 0 else { return nil }
guard let buffer = AVAudioPCMBuffer(pcmFormat: audioFile.processingFormat, frameCapacity: AVAudioFrameCount(length)) else {
return nil
}
try audioFile.read(into: buffer, frameCount: AVAudioFrameCount(length))
guard let channelData = buffer.floatChannelData else { return nil }
let channelCount = Int(audioFile.processingFormat.channelCount)
let frameLength = Int(buffer.frameLength)
let samplesPerBin = max(1, frameLength / bins)
var magnitudes: [Float] = Array(repeating: 0, count: bins)
for bin in 0..<bins {
let start = bin * samplesPerBin
let end = min(frameLength, start + samplesPerBin)
if start >= end { break }
var sum: Float = 0
var sampleCount = 0
for frame in start..<end {
var sampleValue: Float = 0
for channel in 0..<channelCount {
sampleValue += fabsf(channelData[channel][frame])
}
sum += sampleValue / Float(channelCount)
sampleCount += 1
}
magnitudes[bin] = sampleCount > 0 ? sum / Float(sampleCount) : 0
}
if let maxMagnitude = magnitudes.max(), maxMagnitude > 0 {
magnitudes = magnitudes.map { min($0 / maxMagnitude, 1.0) }
}
return magnitudes
} catch {
SecureLogger.error("Waveform extraction failed for \(url.lastPathComponent): \(error)", category: .session)
return nil
}
}
}
}
+1 -18
View File
@@ -87,7 +87,7 @@ import Foundation
/// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy.
/// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships.
struct EphemeralIdentity {
let peerID: String // 8 random bytes
let peerID: PeerID // 8 random bytes
let sessionStart: Date
var handshakeState: HandshakeState
}
@@ -158,23 +158,6 @@ struct IdentityCache: Codable {
var version: Int = 1
}
// MARK: - Identity Resolution
enum IdentityHint {
case unknown
case likelyKnown(fingerprint: String)
case ambiguous(candidates: Set<String>)
case verified(fingerprint: String)
}
// MARK: - Pending Actions
struct PendingActions {
var toggleFavorite: Bool?
var setTrustLevel: TrustLevel?
var setPetname: String?
}
//
// MARK: - Migration Support
+69 -110
View File
@@ -90,27 +90,63 @@
/// - Advanced conflict resolution
///
import BitLogger
import Foundation
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: PeerID) -> [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: PeerID, handshakeState: HandshakeState)
func updateHandshakeState(peerID: PeerID, state: HandshakeState)
// MARK: Cleanup
func clearAllIdentityData()
func removeEphemeralSession(peerID: PeerID)
// 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.
/// Provides thread-safe access to identity mappings with encryption at rest.
/// All identity data is stored encrypted in the device Keychain for security.
class SecureIdentityStateManager {
static let shared = SecureIdentityStateManager()
private let keychain = KeychainManager.shared
final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
private let keychain: KeychainManagerProtocol
private let cacheKey = "bitchat.identityCache.v2"
private let encryptionKeyName = "identityCacheEncryptionKey"
// In-memory state
private var ephemeralSessions: [String: EphemeralIdentity] = [:]
private var ephemeralSessions: [PeerID: EphemeralIdentity] = [:]
private var cryptographicIdentities: [String: CryptographicIdentity] = [:]
private var cache: IdentityCache = IdentityCache()
// Pending actions before handshake
private var pendingActions: [String: PendingActions] = [:]
// Thread safety
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
@@ -122,14 +158,16 @@ class SecureIdentityStateManager {
// Encryption key
private let encryptionKey: SymmetricKey
private init() {
init(_ keychain: KeychainManagerProtocol) {
self.keychain = keychain
// Generate or retrieve encryption key from keychain
let loadedKey: SymmetricKey
// Try to load from keychain
if let keyData = keychain.getIdentityKey(forKey: encryptionKeyName) {
loadedKey = SymmetricKey(data: keyData)
SecureLogger.logKeyOperation("load", keyType: "identity cache encryption key", success: true)
SecureLogger.logKeyOperation(.load, keyType: "identity cache encryption key", success: true)
}
// Generate new key if needed
else {
@@ -137,7 +175,7 @@ class SecureIdentityStateManager {
let keyData = loadedKey.withUnsafeBytes { Data($0) }
// Save to keychain
let saved = keychain.saveIdentityKey(keyData, forKey: encryptionKeyName)
SecureLogger.logKeyOperation("generate", keyType: "identity cache encryption key", success: saved)
SecureLogger.logKeyOperation(.generate, keyType: "identity cache encryption key", success: saved)
}
self.encryptionKey = loadedKey
@@ -146,9 +184,13 @@ class SecureIdentityStateManager {
loadIdentityCache()
}
deinit {
forceSave()
}
// MARK: - Secure Loading/Saving
func loadIdentityCache() {
private func loadIdentityCache() {
guard let encryptedData = keychain.getIdentityKey(forKey: cacheKey) else {
// No existing cache, start fresh
return
@@ -160,16 +202,11 @@ class SecureIdentityStateManager {
cache = try JSONDecoder().decode(IdentityCache.self, from: decryptedData)
} catch {
// Log error but continue with empty cache
SecureLogger.logError(error, context: "Failed to load identity cache", category: SecureLogger.security)
SecureLogger.error(error, context: "Failed to load identity cache", category: .security)
}
}
deinit {
// Force save any pending changes
forceSave()
}
func saveIdentityCache() {
private func saveIdentityCache() {
// Mark that we need to save
pendingSave = true
@@ -191,35 +228,17 @@ class SecureIdentityStateManager {
let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
let saved = keychain.saveIdentityKey(sealedBox.combined!, forKey: cacheKey)
if saved {
SecureLogger.log("Identity cache saved to keychain", category: SecureLogger.security, level: .debug)
SecureLogger.debug("Identity cache saved to keychain", category: .security)
}
} catch {
SecureLogger.logError(error, context: "Failed to save identity cache", category: SecureLogger.security)
SecureLogger.error(error, context: "Failed to save identity cache", category: .security)
}
}
// Force immediate save (for app termination)
func forceSave() {
saveTimer?.invalidate()
if pendingSave {
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
}
performSave()
}
// MARK: - Social Identity Management
@@ -301,23 +320,12 @@ 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
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] {
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: PeerID) -> [CryptographicIdentity] {
queue.sync {
// Defensive: ensure hex and correct length
guard peerID.count == 16, peerID.allSatisfy({ $0.isHexDigit }) else { return [] }
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID) }
}
}
func getAllSocialIdentities() -> [SocialIdentity] {
queue.sync {
return Array(cache.socialIdentities.values)
guard peerID.isShort else { return [] }
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
}
}
@@ -395,7 +403,7 @@ class SecureIdentityStateManager {
}
func setBlocked(_ fingerprint: String, isBlocked: Bool) {
SecureLogger.log("User \(isBlocked ? "blocked" : "unblocked"): \(fingerprint)", category: SecureLogger.security, level: .info)
SecureLogger.info("User \(isBlocked ? "blocked" : "unblocked"): \(fingerprint)", category: .security)
queue.async(flags: .barrier) {
if var identity = self.cache.socialIdentities[fingerprint] {
@@ -447,7 +455,7 @@ class SecureIdentityStateManager {
// MARK: - Ephemeral Session Management
func registerEphemeralSession(peerID: String, handshakeState: HandshakeState = .none) {
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) {
queue.async(flags: .barrier) {
self.ephemeralSessions[peerID] = EphemeralIdentity(
peerID: peerID,
@@ -457,7 +465,7 @@ class SecureIdentityStateManager {
}
}
func updateHandshakeState(peerID: String, state: HandshakeState) {
func updateHandshakeState(peerID: PeerID, state: HandshakeState) {
queue.async(flags: .barrier) {
self.ephemeralSessions[peerID]?.handshakeState = state
@@ -469,81 +477,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
func clearAllIdentityData() {
SecureLogger.log("Clearing all identity data", category: SecureLogger.security, level: .warning)
SecureLogger.warning("Clearing all identity data", category: .security)
queue.async(flags: .barrier) {
self.cache = IdentityCache()
self.ephemeralSessions.removeAll()
self.cryptographicIdentities.removeAll()
self.pendingActions.removeAll()
// Delete from keychain
let deleted = self.keychain.deleteIdentityKey(forKey: self.cacheKey)
SecureLogger.logKeyOperation("delete", keyType: "identity cache", success: deleted)
SecureLogger.logKeyOperation(.delete, keyType: "identity cache", success: deleted)
}
}
func removeEphemeralSession(peerID: String) {
func removeEphemeralSession(peerID: PeerID) {
queue.async(flags: .barrier) {
self.ephemeralSessions.removeValue(forKey: peerID)
self.pendingActions.removeValue(forKey: peerID)
}
}
// MARK: - Verification
func setVerified(fingerprint: String, verified: Bool) {
SecureLogger.log("Fingerprint \(verified ? "verified" : "unverified"): \(fingerprint)", category: SecureLogger.security, level: .info)
SecureLogger.info("Fingerprint \(verified ? "verified" : "unverified"): \(fingerprint)", category: .security)
queue.async(flags: .barrier) {
if verified {
+6 -2
View File
@@ -35,10 +35,14 @@
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
<key>NSCameraUsageDescription</key>
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
<key>NSMicrophoneUsageDescription</key>
<string>bitchat uses the microphone to record voice notes that relay across the mesh.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-central</string>
File diff suppressed because it is too large Load Diff
+355
View File
@@ -0,0 +1,355 @@
//
// BitchatMessage.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
/// Represents a user-visible message in the BitChat system.
/// Handles both broadcast messages and private encrypted messages,
/// with support for mentions, replies, and delivery tracking.
/// - Note: This is the primary data model for chat messages
final class BitchatMessage: Codable {
let id: String
let sender: String
let content: String
let timestamp: Date
let isRelay: Bool
let originalSender: String?
let isPrivate: Bool
let recipientNickname: String?
let senderPeerID: PeerID?
let mentions: [String]? // Array of mentioned nicknames
var deliveryStatus: DeliveryStatus? // Delivery tracking
// Cached formatted text (not included in Codable)
private var _cachedFormattedText: [String: AttributedString] = [:]
func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? {
return _cachedFormattedText["\(isDark)-\(isSelf)"]
}
func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool) {
_cachedFormattedText["\(isDark)-\(isSelf)"] = text
}
// Codable implementation
enum CodingKeys: String, CodingKey {
case id, sender, content, timestamp, isRelay, originalSender
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus
}
init(
id: String? = nil,
sender: String,
content: String,
timestamp: Date,
isRelay: Bool,
originalSender: String? = nil,
isPrivate: Bool = false,
recipientNickname: String? = nil,
senderPeerID: PeerID? = nil,
mentions: [String]? = nil,
deliveryStatus: DeliveryStatus? = nil
) {
self.id = id ?? UUID().uuidString
self.sender = sender
self.content = content
self.timestamp = timestamp
self.isRelay = isRelay
self.originalSender = originalSender
self.isPrivate = isPrivate
self.recipientNickname = recipientNickname
self.senderPeerID = senderPeerID
self.mentions = mentions
self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil)
}
}
// MARK: - Equatable Conformance
extension BitchatMessage: Equatable {
static func == (lhs: BitchatMessage, rhs: BitchatMessage) -> Bool {
return lhs.id == rhs.id &&
lhs.sender == rhs.sender &&
lhs.content == rhs.content &&
lhs.timestamp == rhs.timestamp &&
lhs.isRelay == rhs.isRelay &&
lhs.originalSender == rhs.originalSender &&
lhs.isPrivate == rhs.isPrivate &&
lhs.recipientNickname == rhs.recipientNickname &&
lhs.senderPeerID == rhs.senderPeerID &&
lhs.mentions == rhs.mentions &&
lhs.deliveryStatus == rhs.deliveryStatus
}
}
// MARK: - Binary encoding
extension BitchatMessage {
func toBinaryPayload() -> Data? {
var data = Data()
// Message format:
// - Flags: 1 byte (bit 0: isRelay, bit 1: isPrivate, bit 2: hasOriginalSender, bit 3: hasRecipientNickname, bit 4: hasSenderPeerID, bit 5: hasMentions)
// - Timestamp: 8 bytes (seconds since epoch)
// - ID length: 1 byte
// - ID: variable
// - Sender length: 1 byte
// - Sender: variable
// - Content length: 2 bytes
// - Content: variable
// Optional fields based on flags:
// - Original sender length + data
// - Recipient nickname length + data
// - Sender peer ID length + data
// - Mentions array
var flags: UInt8 = 0
if isRelay { flags |= 0x01 }
if isPrivate { flags |= 0x02 }
if originalSender != nil { flags |= 0x04 }
if recipientNickname != nil { flags |= 0x08 }
if senderPeerID != nil { flags |= 0x10 }
if mentions != nil && !mentions!.isEmpty { flags |= 0x20 }
data.append(flags)
// Timestamp (in milliseconds)
let timestampMillis = UInt64(timestamp.timeIntervalSince1970 * 1000)
// Encode as 8 bytes, big-endian
for i in (0..<8).reversed() {
data.append(UInt8((timestampMillis >> (i * 8)) & 0xFF))
}
// ID
if let idData = id.data(using: .utf8) {
data.append(UInt8(min(idData.count, 255)))
data.append(idData.prefix(255))
} else {
data.append(0)
}
// Sender
if let senderData = sender.data(using: .utf8) {
data.append(UInt8(min(senderData.count, 255)))
data.append(senderData.prefix(255))
} else {
data.append(0)
}
// Content
if let contentData = content.data(using: .utf8) {
let length = UInt16(min(contentData.count, 65535))
// Encode length as 2 bytes, big-endian
data.append(UInt8((length >> 8) & 0xFF))
data.append(UInt8(length & 0xFF))
data.append(contentData.prefix(Int(length)))
} else {
data.append(contentsOf: [0, 0])
}
// Optional fields
if let originalSender = originalSender, let origData = originalSender.data(using: .utf8) {
data.append(UInt8(min(origData.count, 255)))
data.append(origData.prefix(255))
}
if let recipientNickname = recipientNickname, let recipData = recipientNickname.data(using: .utf8) {
data.append(UInt8(min(recipData.count, 255)))
data.append(recipData.prefix(255))
}
if let peerData = senderPeerID?.id.data(using: .utf8) {
data.append(UInt8(min(peerData.count, 255)))
data.append(peerData.prefix(255))
}
// Mentions array
if let mentions = mentions {
data.append(UInt8(min(mentions.count, 255))) // Number of mentions
for mention in mentions.prefix(255) {
if let mentionData = mention.data(using: .utf8) {
data.append(UInt8(min(mentionData.count, 255)))
data.append(mentionData.prefix(255))
} else {
data.append(0)
}
}
}
return data
}
convenience init?(_ data: Data) {
// Create an immutable copy to prevent threading issues
let dataCopy = Data(data)
guard dataCopy.count >= 13 else {
return nil
}
var offset = 0
// Flags
guard offset < dataCopy.count else {
return nil
}
let flags = dataCopy[offset]; offset += 1
let isRelay = (flags & 0x01) != 0
let isPrivate = (flags & 0x02) != 0
let hasOriginalSender = (flags & 0x04) != 0
let hasRecipientNickname = (flags & 0x08) != 0
let hasSenderPeerID = (flags & 0x10) != 0
let hasMentions = (flags & 0x20) != 0
// Timestamp
guard offset + 8 <= dataCopy.count else {
return nil
}
let timestampData = dataCopy[offset..<offset+8]
let timestampMillis = timestampData.reduce(0) { result, byte in
(result << 8) | UInt64(byte)
}
offset += 8
let timestamp = Date(timeIntervalSince1970: TimeInterval(timestampMillis) / 1000.0)
// ID
guard offset < dataCopy.count else {
return nil
}
let idLength = Int(dataCopy[offset]); offset += 1
guard offset + idLength <= dataCopy.count else {
return nil
}
let id = String(data: dataCopy[offset..<offset+idLength], encoding: .utf8) ?? UUID().uuidString
offset += idLength
// Sender
guard offset < dataCopy.count else {
return nil
}
let senderLength = Int(dataCopy[offset]); offset += 1
guard offset + senderLength <= dataCopy.count else {
return nil
}
let sender = String(data: dataCopy[offset..<offset+senderLength], encoding: .utf8) ?? "unknown"
offset += senderLength
// Content
guard offset + 2 <= dataCopy.count else {
return nil
}
let contentLengthData = dataCopy[offset..<offset+2]
let contentLength = Int(contentLengthData.reduce(0) { result, byte in
(result << 8) | UInt16(byte)
})
offset += 2
guard offset + contentLength <= dataCopy.count else {
return nil
}
let content = String(data: dataCopy[offset..<offset+contentLength], encoding: .utf8) ?? ""
offset += contentLength
// Optional fields
var originalSender: String?
if hasOriginalSender && offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
originalSender = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
offset += length
}
}
var recipientNickname: String?
if hasRecipientNickname && offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
recipientNickname = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
offset += length
}
}
var senderPeerID: PeerID?
if hasSenderPeerID && offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
senderPeerID = PeerID(data: dataCopy[offset..<offset+length])
offset += length
}
}
// Mentions array
var mentions: [String]?
if hasMentions && offset < dataCopy.count {
let mentionCount = Int(dataCopy[offset]); offset += 1
if mentionCount > 0 {
mentions = []
for _ in 0..<mentionCount {
if offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
if let mention = String(data: dataCopy[offset..<offset+length], encoding: .utf8) {
mentions?.append(mention)
}
offset += length
}
}
}
}
}
self.init(
id: id,
sender: sender,
content: content,
timestamp: timestamp,
isRelay: isRelay,
originalSender: originalSender,
isPrivate: isPrivate,
recipientNickname: recipientNickname,
senderPeerID: senderPeerID,
mentions: mentions
)
}
}
// MARK: - Helpers
extension BitchatMessage {
private static let timestampFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
return formatter
}()
var formattedTimestamp: String {
Self.timestampFormatter.string(from: timestamp)
}
}
extension Array where Element == BitchatMessage {
/// Filters out empty ones and deduplicate by ID while preserving order (from oldest to newest)
func cleanedAndDeduped() -> [Element] {
let arr = filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false }
guard arr.count > 1 else {
return arr
}
var seen = Set<String>()
var dedup: [BitchatMessage] = []
for m in arr.sorted(by: { $0.timestamp < $1.timestamp }) {
if !seen.contains(m.id) {
dedup.append(m)
seen.insert(m.id)
}
}
return dedup
}
}
+96
View File
@@ -0,0 +1,96 @@
//
// BitchatPacket.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
/// The core packet structure for all BitChat protocol messages.
/// Encapsulates all data needed for routing through the mesh network,
/// including TTL for hop limiting and optional encryption.
/// - Note: Packets larger than BLE MTU (512 bytes) are automatically fragmented
struct BitchatPacket: Codable {
let version: UInt8
let type: UInt8
let senderID: Data
let recipientID: Data?
let timestamp: UInt64
let payload: Data
var signature: Data?
var ttl: UInt8
var route: [Data]?
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil) {
self.version = version
self.type = type
self.senderID = senderID
self.recipientID = recipientID
self.timestamp = timestamp
self.payload = payload
self.signature = signature
self.ttl = ttl
self.route = route
}
// Convenience initializer for new binary format
init(type: UInt8, ttl: UInt8, senderID: PeerID, payload: Data) {
self.version = 1
self.type = type
// Convert hex string peer ID to binary data (8 bytes)
var senderData = Data()
var tempID = senderID.id
while tempID.count >= 2 {
let hexByte = String(tempID.prefix(2))
if let byte = UInt8(hexByte, radix: 16) {
senderData.append(byte)
}
tempID = String(tempID.dropFirst(2))
}
self.senderID = senderData
self.recipientID = nil
self.timestamp = UInt64(Date().timeIntervalSince1970 * 1000) // milliseconds
self.payload = payload
self.signature = nil
self.ttl = ttl
self.route = nil
}
var data: Data? {
BinaryProtocol.encode(self)
}
func toBinaryData(padding: Bool = true) -> Data? {
BinaryProtocol.encode(self, padding: padding)
}
// Backward-compatible helper (defaults to padded encoding)
func toBinaryData() -> Data? {
toBinaryData(padding: true)
}
/// Create binary representation for signing (without signature and TTL fields)
/// TTL is excluded because it changes during packet relay operations
func toBinaryDataForSigning() -> Data? {
// Create a copy without signature and with fixed TTL for signing
// TTL must be excluded because it changes during relay
let unsignedPacket = BitchatPacket(
type: type,
senderID: senderID,
recipientID: recipientID,
timestamp: timestamp,
payload: payload,
signature: nil, // Remove signature for signing
ttl: 0, // Use fixed TTL=0 for signing to ensure relay compatibility
version: version,
route: route
)
return BinaryProtocol.encode(unsignedPacket)
}
static func from(_ data: Data) -> BitchatPacket? {
BinaryProtocol.decode(data)
}
}
+6 -8
View File
@@ -2,8 +2,8 @@ import Foundation
import CoreBluetooth
/// Represents a peer in the BitChat network with all associated metadata
struct BitchatPeer: Identifiable, Equatable {
let id: String // Hex-encoded peer ID
struct BitchatPeer: Equatable {
let peerID: PeerID // Hex-encoded peer ID
let noisePublicKey: Data
let nickname: String
let lastSeen: Date
@@ -51,7 +51,7 @@ struct BitchatPeer: Identifiable, Equatable {
// Display helpers
var displayName: String {
nickname.isEmpty ? String(id.prefix(8)) : nickname
nickname.isEmpty ? String(peerID.id.prefix(8)) : nickname
}
var statusIcon: String {
@@ -73,14 +73,14 @@ struct BitchatPeer: Identifiable, Equatable {
// Initialize from mesh service data
init(
id: String,
peerID: PeerID,
noisePublicKey: Data,
nickname: String,
lastSeen: Date = Date(),
isConnected: Bool = false,
isReachable: Bool = false
) {
self.id = id
self.peerID = peerID
self.noisePublicKey = noisePublicKey
self.nickname = nickname
self.lastSeen = lastSeen
@@ -93,8 +93,6 @@ struct BitchatPeer: Identifiable, Equatable {
}
static func == (lhs: BitchatPeer, rhs: BitchatPeer) -> Bool {
lhs.id == rhs.id
lhs.peerID == rhs.peerID
}
}
//
+58
View File
@@ -0,0 +1,58 @@
//
// CommandsInfo.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
// MARK: - CommandInfo Enum
enum CommandInfo: String, Identifiable {
case block
case clear
case hug
case message = "dm"
case slap
case unblock
case who
case favorite
case unfavorite
var id: String { rawValue }
var alias: String { "/" + rawValue }
var placeholder: String? {
switch self {
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
case .clear, .who:
return nil
}
}
var description: String {
switch self {
case .block: String(localized: "content.commands.block")
case .clear: String(localized: "content.commands.clear")
case .hug: String(localized: "content.commands.hug")
case .message: String(localized: "content.commands.message")
case .slap: String(localized: "content.commands.slap")
case .unblock: String(localized: "content.commands.unblock")
case .who: String(localized: "content.commands.who")
case .favorite: String(localized: "content.commands.favorite")
case .unfavorite: String(localized: "content.commands.unfavorite")
}
}
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .hug, .message, .slap, .who]
if isGeoPublic || isGeoDM {
return baseCommands + [.favorite, .unfavorite]
}
return baseCommands
}
}
+61
View File
@@ -0,0 +1,61 @@
//
// MessagePadding.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
/// Provides privacy-preserving message padding to obscure actual content length.
/// Uses PKCS#7-style padding with random bytes to prevent traffic analysis.
struct MessagePadding {
// Standard block sizes for padding
static let blockSizes = [256, 512, 1024, 2048]
// Add PKCS#7-style padding to reach target size
static func pad(_ data: Data, toSize targetSize: Int) -> Data {
guard data.count < targetSize else { return data }
let paddingNeeded = targetSize - data.count
// Constrain to 255 to fit a single-byte pad length marker
guard paddingNeeded > 0 && paddingNeeded <= 255 else { return data }
var padded = data
// PKCS#7: All pad bytes are equal to the pad length
padded.append(contentsOf: Array(repeating: UInt8(paddingNeeded), count: paddingNeeded))
return padded
}
// Remove padding from data
static func unpad(_ data: Data) -> Data {
guard !data.isEmpty else { return data }
let last = data.last!
let paddingLength = Int(last)
// Must have at least 1 pad byte and not exceed data length
guard paddingLength > 0 && paddingLength <= data.count else { return data }
// Verify PKCS#7: all last N bytes equal to pad length
let start = data.count - paddingLength
let tail = data[start...]
for b in tail { if b != last { return data } }
return Data(data[..<start])
}
// Find optimal block size for data
static func optimalBlockSize(for dataSize: Int) -> Int {
// Account for encryption overhead (~16 bytes for AES-GCM tag)
let totalSize = dataSize + 16
// Find smallest block that fits
for blockSize in blockSizes {
if totalSize <= blockSize {
return blockSize
}
}
// For very large messages, just use the original size
// (will be fragmented anyway)
return dataSize
}
}
+41
View File
@@ -0,0 +1,41 @@
//
// NoisePayload.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
/// Helper to create typed Noise payloads
struct NoisePayload {
let type: NoisePayloadType
let data: Data
/// Encode payload with type prefix
func encode() -> Data {
var encoded = Data()
encoded.append(type.rawValue)
encoded.append(data)
return encoded
}
/// Decode payload from data
static func decode(_ data: Data) -> NoisePayload? {
// Ensure we have at least 1 byte for the type
guard !data.isEmpty else {
return nil
}
// Safely get the first byte
let firstByte = data[data.startIndex]
guard let type = NoisePayloadType(rawValue: firstByte) else {
return nil
}
// Create a proper Data copy (not a subsequence) for thread safety
let payloadData = data.count > 1 ? Data(data.dropFirst()) : Data()
return NoisePayload(type: type, data: payloadData)
}
}
+221
View File
@@ -0,0 +1,221 @@
//
// PeerID.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
struct PeerID: Equatable, Hashable {
enum Prefix: String, CaseIterable {
/// When no prefix is provided
case empty = ""
/// `"mesh:"`
case mesh = "mesh:"
/// `"name:"`
case name = "name:"
/// `"noise:"` (+ 64 characters hex)
case noise = "noise:"
/// `"nostr_"` (+ 16 characters hex)
case geoDM = "nostr_"
/// `"nostr:"` (+ 8 characters hex)
case geoChat = "nostr:"
}
let prefix: Prefix
/// Returns the actual value without any prefix
let bare: String
/// Returns the full `id` value by combining `(prefix + bare)`
var id: String { prefix.rawValue + bare }
// Private so the callers have to go through a convenience init
private init(prefix: Prefix, bare: any StringProtocol) {
self.prefix = prefix
self.bare = String(bare).lowercased()
}
}
// MARK: - Convenience Inits
extension PeerID {
/// Convenience init to create GeoDM PeerID by appending `"nostr_"` to the first 16 characters of `pubKey`
init(nostr_ pubKey: String) {
self.init(prefix: .geoDM, bare: pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength))
}
/// Convenience init to create GeoChat PeerID by appending `"nostr:"` to the first 8 characters of `pubKey`
init(nostr pubKey: String) {
self.init(prefix: .geoChat, bare: pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength))
}
/// Convenience init to create PeerID from String/Substring by splitting it into prefix and bare parts
init(str: any StringProtocol) {
if let prefix = Prefix.allCases.first(where: { $0 != .empty && str.hasPrefix($0.rawValue) }) {
self.init(prefix: prefix, bare: String(str).dropFirst(prefix.rawValue.count))
} else {
self.init(prefix: .empty, bare: str)
}
}
/// Convenience init to handle `Optional<String>`
init?(str: (any StringProtocol)?) {
guard let str else { return nil }
self.init(str: str)
}
/// Convenience init to create PeerID by converting Data to String
init?(data: Data) {
self.init(str: String(data: data, encoding: .utf8))
}
/// Convenience init to "hide" hex-encoding implementation detail
init(hexData: Data) {
self.init(str: hexData.hexEncodedString())
}
/// Convenience init to "hide" hex-encoding implementation detail
init?(hexData: Data?) {
guard let hexData else { return nil }
self.init(hexData: hexData)
}
}
// MARK: - Noise Public Key Helpers
extension PeerID {
/// Derive the stable 16-hex peer ID from a Noise static public key
init(publicKey: Data) {
self.init(str: publicKey.sha256Fingerprint().prefix(16))
}
/// Returns a 16-hex short peer ID derived from a 64-hex Noise public key if needed
func toShort() -> PeerID {
if let noiseKey {
return PeerID(publicKey: noiseKey)
}
return self
}
}
// MARK: - Codable
extension PeerID: Codable {
init(from decoder: any Decoder) throws {
self.init(str: try decoder.singleValueContainer().decode(String.self))
}
func encode(to encoder: any Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(id)
}
}
// MARK: - Helpers
extension PeerID {
var isEmpty: Bool {
id.isEmpty
}
/// Returns true if `id` starts with "`nostr:`"
var isGeoChat: Bool {
prefix == .geoChat
}
/// Returns true if `id` starts with "`nostr_`"
var isGeoDM: Bool {
prefix == .geoDM
}
func toPercentEncoded() -> String {
id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id
}
}
extension PeerID {
var routingData: Data? {
if let direct = Data(hexString: id), direct.count == 8 { return direct }
if let bareData = Data(hexString: bare), bareData.count == 8 { return bareData }
let short = toShort()
return Data(hexString: short.id)
}
init?(routingData: Data) {
guard routingData.count == 8 else { return nil }
self.init(hexData: routingData)
}
}
// MARK: - Validation
extension PeerID {
private enum Constants {
static let maxIDLength = 64
static let hexIDLength = 16 // 8 bytes = 16 hex chars
}
/// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64)
var isValid: Bool {
if prefix != .empty {
return PeerID(str: bare).isValid
}
// Accept short routing IDs (exact 16-hex) or Full Noise key hex (exact 64-hex)
if isShort || isNoiseKeyHex {
return true
}
// If length equals short or full but isn't valid hex, reject
if id.count == Constants.hexIDLength || id.count == Constants.maxIDLength {
return false
}
// Internal format: alphanumeric + dash/underscore up to 63 (not 16 or 64)
let validCharset = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
return !id.isEmpty &&
id.count < Constants.maxIDLength &&
id.rangeOfCharacter(from: validCharset.inverted) == nil
}
/// Returns true if the `bare` id is all hex
var isHex: Bool {
bare.allSatisfy { $0.isHexDigit }
}
/// Short routing IDs (exact 16-hex)
var isShort: Bool {
bare.count == Constants.hexIDLength && isHex
}
/// Full Noise key hex (exact 64-hex)
var isNoiseKeyHex: Bool {
noiseKey != nil
}
/// Full Noise key (exact 64-hex) as Data
var noiseKey: Data? {
guard bare.count == Constants.maxIDLength else { return nil }
return Data(hexString: bare)
}
}
// MARK: - Comparable
extension PeerID: Comparable {
static func < (lhs: PeerID, rhs: PeerID) -> Bool {
lhs.id < rhs.id
}
}
// MARK: - CustomStringConvertible
extension PeerID: CustomStringConvertible {
/// So it returns the actual `id` like before even inside another String
var description: String {
id
}
}
+95
View File
@@ -0,0 +1,95 @@
//
// ReadReceipt.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
struct ReadReceipt: Codable {
let originalMessageID: String
let receiptID: String
var readerID: PeerID // Who read it
let readerNickname: String
let timestamp: Date
init(originalMessageID: String, readerID: PeerID, readerNickname: String) {
self.originalMessageID = originalMessageID
self.receiptID = UUID().uuidString
self.readerID = readerID
self.readerNickname = readerNickname
self.timestamp = Date()
}
// For binary decoding
private init(originalMessageID: String, receiptID: String, readerID: PeerID, readerNickname: String, timestamp: Date) {
self.originalMessageID = originalMessageID
self.receiptID = receiptID
self.readerID = readerID
self.readerNickname = readerNickname
self.timestamp = timestamp
}
func encode() -> Data? {
try? JSONEncoder().encode(self)
}
static func decode(from data: Data) -> ReadReceipt? {
try? JSONDecoder().decode(ReadReceipt.self, from: data)
}
// MARK: - Binary Encoding
func toBinaryData() -> Data {
var data = Data()
data.appendUUID(originalMessageID)
data.appendUUID(receiptID)
// ReaderID as 8-byte hex string
var readerData = Data()
var tempID = readerID.id
while tempID.count >= 2 && readerData.count < 8 {
let hexByte = String(tempID.prefix(2))
if let byte = UInt8(hexByte, radix: 16) {
readerData.append(byte)
}
tempID = String(tempID.dropFirst(2))
}
while readerData.count < 8 {
readerData.append(0)
}
data.append(readerData)
data.appendDate(timestamp)
data.appendString(readerNickname)
return data
}
static func fromBinaryData(_ data: Data) -> ReadReceipt? {
// Create defensive copy
let dataCopy = Data(data)
// Minimum size: 2 UUIDs (32) + readerID (8) + timestamp (8) + min nickname
guard dataCopy.count >= 49 else { return nil }
var offset = 0
guard let originalMessageID = dataCopy.readUUID(at: &offset),
let receiptID = dataCopy.readUUID(at: &offset) else { return nil }
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
let readerID = PeerID(hexData: readerIDData)
guard readerID.isValid else { return nil }
guard let timestamp = dataCopy.readDate(at: &offset),
InputValidator.validateTimestamp(timestamp),
let readerNicknameRaw = dataCopy.readString(at: &offset),
let readerNickname = InputValidator.validateNickname(readerNicknameRaw) else { return nil }
return ReadReceipt(originalMessageID: originalMessageID,
receiptID: receiptID,
readerID: readerID,
readerNickname: readerNickname,
timestamp: timestamp)
}
}
+79
View File
@@ -0,0 +1,79 @@
import Foundation
// REQUEST_SYNC payload TLV (type, length16, value)
// - 0x01: P (uint8) Golomb-Rice parameter
// - 0x02: M (uint32, big-endian) hash range (N * 2^P)
// - 0x03: data (opaque) GR bitstream bytes (MSB-first)
struct RequestSyncPacket {
let p: Int
let m: UInt32
let data: Data
let types: SyncTypeFlags?
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil) {
self.p = p
self.m = m
self.data = data
self.types = types
}
func encode() -> Data {
var out = Data()
func putTLV(_ t: UInt8, _ v: Data) {
out.append(t)
let len = UInt16(v.count)
out.append(UInt8((len >> 8) & 0xFF))
out.append(UInt8(len & 0xFF))
out.append(v)
}
// P
putTLV(0x01, Data([UInt8(p & 0xFF)]))
// M (uint32)
var mBE = m.bigEndian
putTLV(0x02, withUnsafeBytes(of: &mBE) { Data($0) })
// data
putTLV(0x03, data)
if let typesData = types?.toData() {
putTLV(0x04, typesData)
}
return out
}
static func decode(from data: Data, maxAcceptBytes: Int = 1024) -> RequestSyncPacket? {
var off = 0
var p: Int? = nil
var m: UInt32? = nil
var payload: Data? = nil
var types: SyncTypeFlags? = nil
while off + 3 <= data.count {
let t = Int(data[off]); off += 1
guard off + 2 <= data.count else { return nil }
let len = (Int(data[off]) << 8) | Int(data[off+1]); off += 2
guard off + len <= data.count else { return nil }
let v = data.subdata(in: off..<(off+len)); off += len
switch t {
case 0x01:
if v.count == 1 { p = Int(v[0]) }
case 0x02:
if v.count == 4 {
var mm: UInt32 = 0
for b in v { mm = (mm << 8) | UInt32(b) }
m = mm
}
case 0x03:
if v.count > maxAcceptBytes { return nil }
payload = v
case 0x04:
if let decoded = SyncTypeFlags.decode(v) {
types = decoded
}
default:
break // forward compatible; ignore unknown TLVs
}
}
guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil }
return RequestSyncPacket(p: pp, m: mm, data: dd, types: types)
}
}
@@ -1,369 +0,0 @@
//
// NoiseHandshakeCoordinator.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
/// Coordinates Noise handshakes to prevent race conditions and ensure reliable encryption establishment
class NoiseHandshakeCoordinator {
// MARK: - Handshake State
enum HandshakeState: Equatable {
case idle
case waitingToInitiate(since: Date)
case initiating(attempt: Int, lastAttempt: Date)
case responding(since: Date)
case waitingForResponse(messagesSent: [Data], timeout: Date)
case established(since: Date)
case failed(reason: String, canRetry: Bool, lastAttempt: Date)
var isActive: Bool {
switch self {
case .idle, .established, .failed:
return false
default:
return true
}
}
}
// MARK: - Properties
private var handshakeStates: [String: HandshakeState] = [:]
private var handshakeQueue = DispatchQueue(label: "chat.bitchat.noise.handshake", attributes: .concurrent)
// Configuration
private let maxHandshakeAttempts = 3
private let handshakeTimeout: TimeInterval = 10.0
private let retryDelay: TimeInterval = 2.0
private let minTimeBetweenHandshakes: TimeInterval = 1.0 // Reduced from 5.0 for faster recovery
private let establishedSessionTTL: TimeInterval = 300.0 // 5 minutes - sessions older than this can be cleaned up
private let maxEstablishedSessions = 50 // Limit total established sessions
// Track handshake messages to detect duplicates
private var processedHandshakeMessages: Set<Data> = []
private let messageHistoryLimit = 100
// MARK: - Role Determination
/// Deterministically determine who should initiate the handshake
/// Lower peer ID becomes the initiator to prevent simultaneous attempts
func determineHandshakeRole(myPeerID: String, remotePeerID: String) -> NoiseRole {
// Use simple string comparison for deterministic ordering
return myPeerID < remotePeerID ? .initiator : .responder
}
/// Check if we should initiate handshake with a peer
func shouldInitiateHandshake(myPeerID: String, remotePeerID: String, forceIfStale: Bool = false) -> Bool {
return handshakeQueue.sync {
// Check if we're already in an active handshake
if let state = handshakeStates[remotePeerID], state.isActive {
// Check if the handshake is stale and we should force a new one
if forceIfStale {
switch state {
case .initiating(_, let lastAttempt):
if Date().timeIntervalSince(lastAttempt) > handshakeTimeout {
SecureLogger.log("Forcing new handshake with \(remotePeerID) - previous stuck in initiating",
category: SecureLogger.handshake, level: .warning)
return true
}
default:
break
}
}
SecureLogger.log("Already in active handshake with \(remotePeerID), state: \(state)",
category: SecureLogger.handshake, level: .debug)
return false
}
// Check role
let role = determineHandshakeRole(myPeerID: myPeerID, remotePeerID: remotePeerID)
if role != .initiator {
return false
}
// Check if we've failed recently and can't retry yet
if case .failed(_, let canRetry, let lastAttempt) = handshakeStates[remotePeerID] {
if !canRetry {
return false
}
if Date().timeIntervalSince(lastAttempt) < retryDelay {
return false
}
}
return true
}
}
/// Record that we're initiating a handshake
func recordHandshakeInitiation(peerID: String) {
handshakeQueue.async(flags: .barrier) {
let attempt = self.getCurrentAttempt(for: peerID) + 1
self.handshakeStates[peerID] = .initiating(attempt: attempt, lastAttempt: Date())
SecureLogger.log("Recording handshake initiation with \(peerID), attempt \(attempt)",
category: SecureLogger.handshake, level: .info)
}
}
/// Record that we're responding to a handshake
func recordHandshakeResponse(peerID: String) {
handshakeQueue.async(flags: .barrier) {
self.handshakeStates[peerID] = .responding(since: Date())
SecureLogger.log("Recording handshake response to \(peerID)",
category: SecureLogger.handshake, level: .info)
}
}
/// Record successful handshake completion
func recordHandshakeSuccess(peerID: String) {
handshakeQueue.async(flags: .barrier) {
self.handshakeStates[peerID] = .established(since: Date())
SecureLogger.log("Handshake successfully established with \(peerID)",
category: SecureLogger.handshake, level: .info)
}
}
/// Record handshake failure
func recordHandshakeFailure(peerID: String, reason: String) {
handshakeQueue.async(flags: .barrier) {
let attempts = self.getCurrentAttempt(for: peerID)
let canRetry = attempts < self.maxHandshakeAttempts
self.handshakeStates[peerID] = .failed(reason: reason, canRetry: canRetry, lastAttempt: Date())
SecureLogger.log("Handshake failed with \(peerID): \(reason), canRetry: \(canRetry)",
category: SecureLogger.handshake, level: .warning)
}
}
/// Check if we should accept an incoming handshake initiation
func shouldAcceptHandshakeInitiation(myPeerID: String, remotePeerID: String) -> Bool {
return handshakeQueue.sync {
// If we're already established, reject new handshakes
if case .established = handshakeStates[remotePeerID] {
SecureLogger.log("Rejecting handshake from \(remotePeerID) - already established",
category: SecureLogger.handshake, level: .debug)
return false
}
let role = determineHandshakeRole(myPeerID: myPeerID, remotePeerID: remotePeerID)
// If we're the initiator and already initiating, this is a race condition
if role == .initiator {
if case .initiating = handshakeStates[remotePeerID] {
// They shouldn't be initiating, but accept it to recover from race condition
SecureLogger.log("Accepting handshake from \(remotePeerID) despite being initiator (race condition recovery)",
category: SecureLogger.handshake, level: .warning)
return true
}
}
// If we're the responder, we should accept
return true
}
}
/// Check if this is a duplicate handshake message
func isDuplicateHandshakeMessage(_ data: Data) -> Bool {
return handshakeQueue.sync {
if processedHandshakeMessages.contains(data) {
return true
}
// Add to processed messages with size limit
if processedHandshakeMessages.count >= messageHistoryLimit {
processedHandshakeMessages.removeAll()
}
processedHandshakeMessages.insert(data)
return false
}
}
/// Get time to wait before next handshake attempt
func getRetryDelay(for peerID: String) -> TimeInterval? {
return handshakeQueue.sync {
guard let state = handshakeStates[peerID] else { return nil }
switch state {
case .failed(_, let canRetry, let lastAttempt):
if !canRetry { return nil }
let timeSinceFailure = Date().timeIntervalSince(lastAttempt)
if timeSinceFailure >= retryDelay {
return 0
}
return retryDelay - timeSinceFailure
case .initiating(_, let lastAttempt):
let timeSinceAttempt = Date().timeIntervalSince(lastAttempt)
if timeSinceAttempt >= minTimeBetweenHandshakes {
return 0
}
return minTimeBetweenHandshakes - timeSinceAttempt
default:
return nil
}
}
}
/// Reset handshake state for a peer
func resetHandshakeState(for peerID: String) {
handshakeQueue.async(flags: .barrier) {
self.handshakeStates.removeValue(forKey: peerID)
SecureLogger.log("Reset handshake state for \(peerID)",
category: SecureLogger.handshake, level: .debug)
}
}
/// Clean up stale handshake states and old established sessions
func cleanupStaleHandshakes(staleTimeout: TimeInterval = 30.0) -> [String] {
return handshakeQueue.sync {
let now = Date()
var stalePeerIDs: [String] = []
var establishedSessions: [(peerID: String, since: Date)] = []
for (peerID, state) in handshakeStates {
var isStale = false
switch state {
case .initiating(_, let lastAttempt):
if now.timeIntervalSince(lastAttempt) > staleTimeout {
isStale = true
}
case .responding(let since):
if now.timeIntervalSince(since) > staleTimeout {
isStale = true
}
case .waitingForResponse(_, let timeout):
if now > timeout {
isStale = true
}
case .established(let since):
// Track established sessions for potential cleanup
establishedSessions.append((peerID, since))
// Clean up very old established sessions
if now.timeIntervalSince(since) > establishedSessionTTL {
isStale = true
}
default:
break
}
if isStale {
stalePeerIDs.append(peerID)
SecureLogger.log("Found stale handshake state for \(peerID): \(state)",
category: SecureLogger.handshake, level: .warning)
}
}
// If we have too many established sessions, clean up the oldest ones
if establishedSessions.count > maxEstablishedSessions {
// Sort by age (oldest first)
let sortedSessions = establishedSessions.sorted { $0.since < $1.since }
let sessionsToRemove = sortedSessions.count - maxEstablishedSessions
for i in 0..<sessionsToRemove {
let peerID = sortedSessions[i].peerID
stalePeerIDs.append(peerID)
SecureLogger.log("Removing old established session for \(peerID) to maintain session limit",
category: SecureLogger.handshake, level: .info)
}
}
// Clean up stale states
for peerID in stalePeerIDs {
handshakeStates.removeValue(forKey: peerID)
}
if !stalePeerIDs.isEmpty {
SecureLogger.log("Cleaned up \(stalePeerIDs.count) stale handshake states",
category: SecureLogger.handshake, level: .info)
}
return stalePeerIDs
}
}
/// Get current handshake state
func getHandshakeState(for peerID: String) -> HandshakeState {
return handshakeQueue.sync {
return handshakeStates[peerID] ?? .idle
}
}
/// Get current retry count for a peer
func getRetryCount(for peerID: String) -> Int {
return handshakeQueue.sync {
switch handshakeStates[peerID] {
case .initiating(let attempt, _):
return attempt - 1 // Attempts start at 1, retries start at 0
default:
return 0
}
}
}
/// Increment retry count for a peer
func incrementRetryCount(for peerID: String) {
handshakeQueue.async(flags: .barrier) {
let currentAttempt = self.getCurrentAttempt(for: peerID)
self.handshakeStates[peerID] = .initiating(attempt: currentAttempt + 1, lastAttempt: Date())
}
}
// MARK: - Private Helpers
private func getCurrentAttempt(for peerID: String) -> Int {
switch handshakeStates[peerID] {
case .initiating(let attempt, _):
return attempt
case .failed(_, _, _):
// Count previous attempts
return 1 // Simplified for now
default:
return 0
}
}
/// Log current handshake states for debugging
func logHandshakeStates() {
handshakeQueue.sync {
SecureLogger.log("=== Handshake States ===", category: SecureLogger.handshake, level: .debug)
for (peerID, state) in handshakeStates {
let stateDesc: String
switch state {
case .idle:
stateDesc = "idle"
case .waitingToInitiate(let since):
stateDesc = "waiting to initiate (since \(since))"
case .initiating(let attempt, let lastAttempt):
stateDesc = "initiating (attempt \(attempt), last: \(lastAttempt))"
case .responding(let since):
stateDesc = "responding (since: \(since))"
case .waitingForResponse(let messages, let timeout):
stateDesc = "waiting for response (\(messages.count) messages, timeout: \(timeout))"
case .established(let since):
stateDesc = "established (since \(since))"
case .failed(let reason, let canRetry, let lastAttempt):
stateDesc = "failed: \(reason) (canRetry: \(canRetry), last: \(lastAttempt))"
}
SecureLogger.log(" \(peerID): \(stateDesc)", category: SecureLogger.handshake, level: .debug)
}
SecureLogger.log("========================", category: SecureLogger.handshake, level: .debug)
}
}
/// Clear all handshake states - used during panic mode
func clearAllHandshakeStates() {
handshakeQueue.async(flags: .barrier) {
SecureLogger.log("Clearing all handshake states for panic mode", category: SecureLogger.handshake, level: .warning)
self.handshakeStates.removeAll()
self.processedHandshakeMessages.removeAll()
}
}
}
+64 -43
View File
@@ -77,9 +77,9 @@
/// - Noise Specification: http://www.noiseprotocol.org/noise.html
///
import BitLogger
import Foundation
import CryptoKit
import os.log
// Core Noise Protocol implementation
// Based on the Noise Protocol Framework specification
@@ -127,7 +127,7 @@ struct NoiseProtocolName {
/// Handles ChaCha20-Poly1305 AEAD encryption with automatic nonce management
/// and replay protection using a sliding window algorithm.
/// - Warning: Nonce reuse would be catastrophic for security
class NoiseCipherState {
final class NoiseCipherState {
// Constants for replay protection
private static let NONCE_SIZE_BYTES = 4
private static let REPLAY_WINDOW_SIZE = 1024
@@ -222,7 +222,7 @@ class NoiseCipherState {
guard combinedPayload.count >= Self.NONCE_SIZE_BYTES else {
return nil
}
// Extract 4-byte nonce (big-endian)
let nonceData = combinedPayload.prefix(Self.NONCE_SIZE_BYTES)
let extractedNonce = nonceData.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) -> UInt64 in
@@ -233,18 +233,18 @@ class NoiseCipherState {
}
return result
}
// Extract ciphertext (remaining bytes)
let ciphertext = combinedPayload.dropFirst(Self.NONCE_SIZE_BYTES)
return (nonce: extractedNonce, ciphertext: Data(ciphertext))
}
/// Convert nonce to 4-byte array (big-endian)
private func nonceToBytes(_ nonce: UInt64) -> Data {
var bytes = Data(count: Self.NONCE_SIZE_BYTES)
withUnsafeBytes(of: nonce.bigEndian) { ptr in
// Copy only the last 4 bytes from the 8-byte UInt64
// Copy only the last 4 bytes from the 8-byte UInt64
let sourceBytes = ptr.bindMemory(to: UInt8.self)
bytes.replaceSubrange(0..<Self.NONCE_SIZE_BYTES, with: sourceBytes.suffix(Self.NONCE_SIZE_BYTES))
}
@@ -273,7 +273,7 @@ class NoiseCipherState {
let sealedBox = try ChaChaPoly.seal(plaintext, using: key, nonce: ChaChaPoly.Nonce(data: nonceData), authenticating: associatedData)
// increment local nonce
nonce += 1
// Create combined payload: <nonce><ciphertext>
let combinedPayload: Data
if (useExtractedNonce) {
@@ -285,9 +285,9 @@ class NoiseCipherState {
// Log high nonce values that might indicate issues
if currentNonce > Self.HIGH_NONCE_WARNING_THRESHOLD {
SecureLogger.log("High nonce value detected: \(currentNonce) - consider rekeying", category: SecureLogger.encryption, level: .warning)
SecureLogger.warning("High nonce value detected: \(currentNonce) - consider rekeying", category: .encryption)
}
return combinedPayload
}
@@ -307,16 +307,16 @@ class NoiseCipherState {
if useExtractedNonce {
// Extract nonce and ciphertext from combined payload
guard let (extractedNonce, actualCiphertext) = try extractNonceFromCiphertextPayload(ciphertext) else {
SecureLogger.log("Decrypt failed: Could not extract nonce from payload")
SecureLogger.debug("Decrypt failed: Could not extract nonce from payload")
throw NoiseError.invalidCiphertext
}
// Validate nonce with sliding window replay protection
guard isValidNonce(extractedNonce) else {
SecureLogger.log("Replay attack detected: nonce \(extractedNonce) rejected")
SecureLogger.debug("Replay attack detected: nonce \(extractedNonce) rejected")
throw NoiseError.replayDetected
}
// Split ciphertext and tag
encryptedData = actualCiphertext.prefix(actualCiphertext.count - 16)
tag = actualCiphertext.suffix(16)
@@ -342,7 +342,7 @@ class NoiseCipherState {
// Log high nonce values that might indicate issues
if decryptionNonce > Self.HIGH_NONCE_WARNING_THRESHOLD {
SecureLogger.log("High nonce value detected: \(decryptionNonce) - consider rekeying", category: SecureLogger.encryption, level: .warning)
SecureLogger.warning("High nonce value detected: \(decryptionNonce) - consider rekeying", category: .encryption)
}
do {
@@ -355,9 +355,9 @@ class NoiseCipherState {
nonce += 1
return plaintext
} catch {
SecureLogger.log("Decrypt failed: \(error) for nonce \(decryptionNonce)")
SecureLogger.debug("Decrypt failed: \(error) for nonce \(decryptionNonce)")
// Log authentication failures with nonce info
SecureLogger.log("Decryption failed at nonce \(decryptionNonce)", category: SecureLogger.encryption, level: .error)
SecureLogger.error("Decryption failed at nonce \(decryptionNonce)", category: .encryption)
throw error
}
}
@@ -384,7 +384,7 @@ class NoiseCipherState {
/// Responsible for key derivation, protocol name hashing, and maintaining
/// the chaining key that provides key separation between handshake messages.
/// - Note: This class implements the SymmetricState object from the Noise spec
class NoiseSymmetricState {
final class NoiseSymmetricState {
private var cipherState: NoiseCipherState
private var chainingKey: Data
private var hash: Data
@@ -397,7 +397,7 @@ class NoiseSymmetricState {
if nameData.count <= 32 {
self.hash = nameData + Data(repeating: 0, count: 32 - nameData.count)
} else {
self.hash = Data(SHA256.hash(data: nameData))
self.hash = nameData.sha256Hash()
}
self.chainingKey = self.hash
}
@@ -410,7 +410,7 @@ class NoiseSymmetricState {
}
func mixHash(_ data: Data) {
hash = Data(SHA256.hash(data: hash + data))
hash = (hash + data).sha256Hash()
}
func mixKeyAndHash(_ inputKeyMaterial: Data) {
@@ -451,13 +451,13 @@ class NoiseSymmetricState {
}
}
func split() -> (NoiseCipherState, NoiseCipherState) {
func split(useExtractedNonce: Bool) -> (NoiseCipherState, NoiseCipherState) {
let output = hkdf(chainingKey: chainingKey, inputKeyMaterial: Data(), numOutputs: 2)
let tempKey1 = SymmetricKey(data: output[0])
let tempKey2 = SymmetricKey(data: output[1])
let c1 = NoiseCipherState(key: tempKey1, useExtractedNonce: true)
let c2 = NoiseCipherState(key: tempKey2, useExtractedNonce: true)
let c1 = NoiseCipherState(key: tempKey1, useExtractedNonce: useExtractedNonce)
let c2 = NoiseCipherState(key: tempKey2, useExtractedNonce: useExtractedNonce)
return (c1, c2)
}
@@ -488,9 +488,10 @@ class NoiseSymmetricState {
/// This is the main interface for establishing encrypted sessions between peers.
/// Manages the handshake state machine, message patterns, and key derivation.
/// - Important: Each handshake instance should only be used once
class NoiseHandshakeState {
final class NoiseHandshakeState {
private let role: NoiseRole
private let pattern: NoisePattern
private let keychain: KeychainManagerProtocol
private var symmetricState: NoiseSymmetricState
// Keys
@@ -506,9 +507,24 @@ class NoiseHandshakeState {
private var messagePatterns: [[NoiseMessagePattern]] = []
private var currentPattern = 0
init(role: NoiseRole, pattern: NoisePattern, localStaticKey: Curve25519.KeyAgreement.PrivateKey? = nil, remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil) {
// Test support: predetermined ephemeral keys for test vectors
private var predeterminedEphemeralKey: Curve25519.KeyAgreement.PrivateKey?
private var prologueData: Data
init(
role: NoiseRole,
pattern: NoisePattern,
keychain: KeychainManagerProtocol,
localStaticKey: Curve25519.KeyAgreement.PrivateKey? = nil,
remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil,
prologue: Data = Data(),
predeterminedEphemeralKey: Curve25519.KeyAgreement.PrivateKey? = nil
) {
self.role = role
self.pattern = pattern
self.keychain = keychain
self.prologueData = prologue
self.predeterminedEphemeralKey = predeterminedEphemeralKey
// Initialize static keys
if let localKey = localStaticKey {
@@ -529,8 +545,8 @@ class NoiseHandshakeState {
}
private func mixPreMessageKeys() {
// Mix prologue (empty for XX pattern normally)
symmetricState.mixHash(Data()) // Empty prologue for XX pattern
// Mix prologue
symmetricState.mixHash(self.prologueData)
// For XX pattern, no pre-message keys
// For IK/NK patterns, we'd mix the responder's static key here
switch pattern {
@@ -548,15 +564,20 @@ class NoiseHandshakeState {
guard currentPattern < messagePatterns.count else {
throw NoiseError.handshakeComplete
}
var messageBuffer = Data()
let patterns = messagePatterns[currentPattern]
for pattern in patterns {
switch pattern {
case .e:
// Generate ephemeral key
localEphemeralPrivate = Curve25519.KeyAgreement.PrivateKey()
// Generate ephemeral key (or use predetermined key for tests)
if let predetermined = predeterminedEphemeralKey {
localEphemeralPrivate = predetermined
predeterminedEphemeralKey = nil
} else {
localEphemeralPrivate = Curve25519.KeyAgreement.PrivateKey()
}
localEphemeralPublic = localEphemeralPrivate!.publicKey
messageBuffer.append(localEphemeralPublic!.rawRepresentation)
symmetricState.mixHash(localEphemeralPublic!.rawRepresentation)
@@ -579,7 +600,7 @@ class NoiseHandshakeState {
var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData)
// Clear sensitive shared secret
KeychainManager.secureClear(&sharedData)
keychain.secureClear(&sharedData)
case .es:
// DH(ephemeral, static) - direction depends on role
@@ -627,7 +648,7 @@ class NoiseHandshakeState {
var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData)
// Clear sensitive shared secret
KeychainManager.secureClear(&sharedData)
keychain.secureClear(&sharedData)
}
}
@@ -644,7 +665,7 @@ class NoiseHandshakeState {
guard currentPattern < messagePatterns.count else {
throw NoiseError.handshakeComplete
}
var buffer = message
let patterns = messagePatterns[currentPattern]
@@ -661,7 +682,7 @@ class NoiseHandshakeState {
do {
remoteEphemeralPublic = try NoiseHandshakeState.validatePublicKey(ephemeralData)
} catch {
SecureLogger.log("Invalid ephemeral public key received", category: SecureLogger.security, level: .warning)
SecureLogger.warning("Invalid ephemeral public key received", category: .security)
throw NoiseError.invalidMessage
}
symmetricState.mixHash(ephemeralData)
@@ -678,7 +699,7 @@ class NoiseHandshakeState {
let decrypted = try symmetricState.decryptAndHash(staticData)
remoteStaticPublic = try NoiseHandshakeState.validatePublicKey(decrypted)
} catch {
SecureLogger.logSecurityEvent(.authenticationFailed(peerID: "Unknown - handshake"), level: .error)
SecureLogger.error(.authenticationFailed(peerID: "Unknown - handshake"))
throw NoiseError.authenticationFailure
}
@@ -715,7 +736,7 @@ class NoiseHandshakeState {
var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData)
// Clear sensitive shared secret
KeychainManager.secureClear(&sharedData)
keychain.secureClear(&sharedData)
} else {
guard let localStatic = localStaticPrivate,
let remoteEphemeral = remoteEphemeralPublic else {
@@ -725,7 +746,7 @@ class NoiseHandshakeState {
var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData)
// Clear sensitive shared secret
KeychainManager.secureClear(&sharedData)
keychain.secureClear(&sharedData)
}
case .se:
@@ -738,7 +759,7 @@ class NoiseHandshakeState {
var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData)
// Clear sensitive shared secret
KeychainManager.secureClear(&sharedData)
keychain.secureClear(&sharedData)
} else {
guard let localEphemeral = localEphemeralPrivate,
let remoteStatic = remoteStaticPublic else {
@@ -748,7 +769,7 @@ class NoiseHandshakeState {
var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData)
// Clear sensitive shared secret
KeychainManager.secureClear(&sharedData)
keychain.secureClear(&sharedData)
}
case .ss:
@@ -759,7 +780,7 @@ class NoiseHandshakeState {
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteStatic)
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
default:
case .e, .s:
break
}
}
@@ -768,12 +789,12 @@ class NoiseHandshakeState {
return currentPattern >= messagePatterns.count
}
func getTransportCiphers() throws -> (send: NoiseCipherState, receive: NoiseCipherState) {
func getTransportCiphers(useExtractedNonce: Bool) throws -> (send: NoiseCipherState, receive: NoiseCipherState) {
guard isHandshakeComplete() else {
throw NoiseError.handshakeNotComplete
}
let (c1, c2) = symmetricState.split()
let (c1, c2) = symmetricState.split(useExtractedNonce: useExtractedNonce)
// Initiator uses c1 for sending, c2 for receiving
// Responder uses c2 for sending, c1 for receiving
@@ -877,7 +898,7 @@ extension NoiseHandshakeState {
// Check against known bad points
if lowOrderPoints.contains(keyData) {
SecureLogger.log("Low-order point detected", category: SecureLogger.security, level: .warning)
SecureLogger.warning("Low-order point detected", category: .security)
throw NoiseError.invalidPublicKey
}
@@ -887,7 +908,7 @@ extension NoiseHandshakeState {
return publicKey
} catch {
// If CryptoKit rejects it, it's invalid
SecureLogger.log("CryptoKit validation failed", category: SecureLogger.security, level: .warning)
SecureLogger.warning("CryptoKit validation failed", category: .security)
throw NoiseError.invalidPublicKey
}
}
+95
View File
@@ -0,0 +1,95 @@
//
// NoiseRateLimiter.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitLogger
import Foundation
final class NoiseRateLimiter {
private var handshakeTimestamps: [PeerID: [Date]] = [:]
private var messageTimestamps: [PeerID: [Date]] = [:]
// Global rate limiting
private var globalHandshakeTimestamps: [Date] = []
private var globalMessageTimestamps: [Date] = []
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
func allowHandshake(from peerID: PeerID) -> Bool {
return queue.sync(flags: .barrier) {
let now = Date()
let oneMinuteAgo = now.addingTimeInterval(-60)
// Check global rate limit first
globalHandshakeTimestamps = globalHandshakeTimestamps.filter { $0 > oneMinuteAgo }
if globalHandshakeTimestamps.count >= NoiseSecurityConstants.maxGlobalHandshakesPerMinute {
SecureLogger.warning("Global handshake rate limit exceeded: \(globalHandshakeTimestamps.count)/\(NoiseSecurityConstants.maxGlobalHandshakesPerMinute) per minute", category: .security)
return false
}
// Check per-peer rate limit
var timestamps = handshakeTimestamps[peerID] ?? []
timestamps = timestamps.filter { $0 > oneMinuteAgo }
if timestamps.count >= NoiseSecurityConstants.maxHandshakesPerMinute {
SecureLogger.warning("Per-peer handshake rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxHandshakesPerMinute) per minute", category: .security)
return false
}
// Record new handshake
timestamps.append(now)
handshakeTimestamps[peerID] = timestamps
globalHandshakeTimestamps.append(now)
return true
}
}
func allowMessage(from peerID: PeerID) -> Bool {
return queue.sync(flags: .barrier) {
let now = Date()
let oneSecondAgo = now.addingTimeInterval(-1)
// Check global rate limit first
globalMessageTimestamps = globalMessageTimestamps.filter { $0 > oneSecondAgo }
if globalMessageTimestamps.count >= NoiseSecurityConstants.maxGlobalMessagesPerSecond {
SecureLogger.warning("Global message rate limit exceeded: \(globalMessageTimestamps.count)/\(NoiseSecurityConstants.maxGlobalMessagesPerSecond) per second", category: .security)
return false
}
// Check per-peer rate limit
var timestamps = messageTimestamps[peerID] ?? []
timestamps = timestamps.filter { $0 > oneSecondAgo }
if timestamps.count >= NoiseSecurityConstants.maxMessagesPerSecond {
SecureLogger.warning("Per-peer message rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxMessagesPerSecond) per second", category: .security)
return false
}
// Record new message
timestamps.append(now)
messageTimestamps[peerID] = timestamps
globalMessageTimestamps.append(now)
return true
}
}
func reset(for peerID: PeerID) {
queue.async(flags: .barrier) {
self.handshakeTimestamps.removeValue(forKey: peerID)
self.messageTimestamps.removeValue(forKey: peerID)
}
}
func resetAll() {
queue.async(flags: .barrier) {
self.handshakeTimestamps.removeAll()
self.messageTimestamps.removeAll()
self.globalHandshakeTimestamps.removeAll()
self.globalMessageTimestamps.removeAll()
}
}
}
@@ -1,223 +0,0 @@
//
// NoiseSecurityConsiderations.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
import CryptoKit
// MARK: - Security Constants
enum NoiseSecurityConstants {
// Maximum message size to prevent memory exhaustion
static let maxMessageSize = 65535 // 64KB as per Noise spec
// Maximum handshake message size
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
// Session timeout - sessions older than this should be renegotiated
static let sessionTimeout: TimeInterval = 86400 // 24 hours
// Maximum number of messages before rekey (2^64 - 1 is the nonce limit)
static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages
// Handshake timeout - abandon incomplete handshakes
static let handshakeTimeout: TimeInterval = 60 // 1 minute
// Maximum concurrent sessions per peer
static let maxSessionsPerPeer = 3
// Rate limiting
static let maxHandshakesPerMinute = 10
static let maxMessagesPerSecond = 100
// Global rate limiting (across all peers)
static let maxGlobalHandshakesPerMinute = 30
static let maxGlobalMessagesPerSecond = 500
}
// MARK: - Security Validations
struct NoiseSecurityValidator {
/// Validate message size
static func validateMessageSize(_ data: Data) -> Bool {
return data.count <= NoiseSecurityConstants.maxMessageSize
}
/// Validate handshake message size
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
}
/// Validate peer ID format using unified validator
static func validatePeerID(_ peerID: String) -> Bool {
return InputValidator.validatePeerID(peerID)
}
}
// MARK: - Enhanced Noise Session with Security
class SecureNoiseSession: NoiseSession {
private(set) var messageCount: UInt64 = 0
private let sessionStartTime = Date()
private(set) var lastActivityTime = Date()
override func encrypt(_ plaintext: Data) throws -> Data {
// Check session age
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
throw NoiseSecurityError.sessionExpired
}
// Check message count
if messageCount >= NoiseSecurityConstants.maxMessagesPerSession {
throw NoiseSecurityError.sessionExhausted
}
// Validate message size
guard NoiseSecurityValidator.validateMessageSize(plaintext) else {
throw NoiseSecurityError.messageTooLarge
}
let encrypted = try super.encrypt(plaintext)
messageCount += 1
lastActivityTime = Date()
return encrypted
}
override func decrypt(_ ciphertext: Data) throws -> Data {
// Check session age
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
throw NoiseSecurityError.sessionExpired
}
// Validate message size
guard NoiseSecurityValidator.validateMessageSize(ciphertext) else {
throw NoiseSecurityError.messageTooLarge
}
let decrypted = try super.decrypt(ciphertext)
lastActivityTime = Date()
return decrypted
}
func needsRenegotiation() -> Bool {
// Check if we've used more than 90% of message limit
let messageThreshold = UInt64(Double(NoiseSecurityConstants.maxMessagesPerSession) * 0.9)
if messageCount >= messageThreshold {
return true
}
// Check if last activity was more than 30 minutes ago
if Date().timeIntervalSince(lastActivityTime) > NoiseSecurityConstants.sessionTimeout {
return true
}
return false
}
// MARK: - Testing Support
#if DEBUG
func setLastActivityTimeForTesting(_ date: Date) {
lastActivityTime = date
}
func setMessageCountForTesting(_ count: UInt64) {
messageCount = count
}
#endif
}
// MARK: - Rate Limiter
class NoiseRateLimiter {
private var handshakeTimestamps: [String: [Date]] = [:] // peerID -> timestamps
private var messageTimestamps: [String: [Date]] = [:] // peerID -> timestamps
// Global rate limiting
private var globalHandshakeTimestamps: [Date] = []
private var globalMessageTimestamps: [Date] = []
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
func allowHandshake(from peerID: String) -> Bool {
return queue.sync(flags: .barrier) {
let now = Date()
let oneMinuteAgo = now.addingTimeInterval(-60)
// Check global rate limit first
globalHandshakeTimestamps = globalHandshakeTimestamps.filter { $0 > oneMinuteAgo }
if globalHandshakeTimestamps.count >= NoiseSecurityConstants.maxGlobalHandshakesPerMinute {
SecureLogger.log("Global handshake rate limit exceeded: \(globalHandshakeTimestamps.count)/\(NoiseSecurityConstants.maxGlobalHandshakesPerMinute) per minute", category: SecureLogger.security, level: .warning)
return false
}
// Check per-peer rate limit
var timestamps = handshakeTimestamps[peerID] ?? []
timestamps = timestamps.filter { $0 > oneMinuteAgo }
if timestamps.count >= NoiseSecurityConstants.maxHandshakesPerMinute {
SecureLogger.log("Per-peer handshake rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxHandshakesPerMinute) per minute", category: SecureLogger.security, level: .warning)
return false
}
// Record new handshake
timestamps.append(now)
handshakeTimestamps[peerID] = timestamps
globalHandshakeTimestamps.append(now)
return true
}
}
func allowMessage(from peerID: String) -> Bool {
return queue.sync(flags: .barrier) {
let now = Date()
let oneSecondAgo = now.addingTimeInterval(-1)
// Check global rate limit first
globalMessageTimestamps = globalMessageTimestamps.filter { $0 > oneSecondAgo }
if globalMessageTimestamps.count >= NoiseSecurityConstants.maxGlobalMessagesPerSecond {
SecureLogger.log("Global message rate limit exceeded: \(globalMessageTimestamps.count)/\(NoiseSecurityConstants.maxGlobalMessagesPerSecond) per second", category: SecureLogger.security, level: .warning)
return false
}
// Check per-peer rate limit
var timestamps = messageTimestamps[peerID] ?? []
timestamps = timestamps.filter { $0 > oneSecondAgo }
if timestamps.count >= NoiseSecurityConstants.maxMessagesPerSecond {
SecureLogger.log("Per-peer message rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxMessagesPerSecond) per second", category: SecureLogger.security, level: .warning)
return false
}
// Record new message
timestamps.append(now)
messageTimestamps[peerID] = timestamps
globalMessageTimestamps.append(now)
return true
}
}
func reset(for peerID: String) {
queue.async(flags: .barrier) {
self.handshakeTimestamps.removeValue(forKey: peerID)
self.messageTimestamps.removeValue(forKey: peerID)
}
}
}
// MARK: - Security Errors
enum NoiseSecurityError: Error {
case sessionExpired
case sessionExhausted
case messageTooLarge
case invalidPeerID
case rateLimitExceeded
case handshakeTimeout
}
@@ -0,0 +1,37 @@
//
// NoiseSecurityConstants.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
enum NoiseSecurityConstants {
// Maximum message size to prevent memory exhaustion
static let maxMessageSize = 65535 // 64KB as per Noise spec
// Maximum handshake message size
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
// Session timeout - sessions older than this should be renegotiated
static let sessionTimeout: TimeInterval = 86400 // 24 hours
// Maximum number of messages before rekey (2^64 - 1 is the nonce limit)
static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages
// Handshake timeout - abandon incomplete handshakes
static let handshakeTimeout: TimeInterval = 60 // 1 minute
// Maximum concurrent sessions per peer
static let maxSessionsPerPeer = 3
// Rate limiting
static let maxHandshakesPerMinute = 10
static let maxMessagesPerSecond = 100
// Global rate limiting (across all peers)
static let maxGlobalHandshakesPerMinute = 30
static let maxGlobalMessagesPerSecond = 500
}
+18
View File
@@ -0,0 +1,18 @@
//
// NoiseSecurityError.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
enum NoiseSecurityError: Error {
case sessionExpired
case sessionExhausted
case messageTooLarge
case invalidPeerID
case rateLimitExceeded
case handshakeTimeout
}
@@ -0,0 +1,22 @@
//
// NoiseSecurityValidator.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
struct NoiseSecurityValidator {
/// Validate message size
static func validateMessageSize(_ data: Data) -> Bool {
return data.count <= NoiseSecurityConstants.maxMessageSize
}
/// Validate handshake message size
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
}
}
+26 -274
View File
@@ -6,37 +6,14 @@
// For more information, see <https://unlicense.org>
//
import BitLogger
import Foundation
import CryptoKit
import os.log
// MARK: - Noise Session State
enum NoiseSessionState: Equatable {
case uninitialized
case handshaking
case established
case failed(Error)
static func == (lhs: NoiseSessionState, rhs: NoiseSessionState) -> Bool {
switch (lhs, rhs) {
case (.uninitialized, .uninitialized),
(.handshaking, .handshaking),
(.established, .established):
return true
case (.failed, .failed):
return true // We don't compare the errors
default:
return false
}
}
}
// MARK: - Noise Session
class NoiseSession {
let peerID: String
let peerID: PeerID
let role: NoiseRole
private let keychain: KeychainManagerProtocol
private var state: NoiseSessionState = .uninitialized
private var handshakeState: NoiseHandshakeState?
private var sendCipher: NoiseCipherState?
@@ -53,9 +30,16 @@ class NoiseSession {
// Thread safety
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: PeerID,
role: NoiseRole,
keychain: KeychainManagerProtocol,
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil
) {
self.peerID = peerID
self.role = role
self.keychain = keychain
self.localStaticKey = localStaticKey
self.remoteStaticPublicKey = remoteStaticKey
}
@@ -72,6 +56,7 @@ class NoiseSession {
handshakeState = NoiseHandshakeState(
role: role,
pattern: .XX,
keychain: keychain,
localStaticKey: localStaticKey,
remoteStaticKey: nil
)
@@ -92,18 +77,19 @@ class NoiseSession {
func processHandshakeMessage(_ message: Data) throws -> Data? {
return try sessionQueue.sync(flags: .barrier) {
SecureLogger.log("NoiseSession[\(peerID)]: Processing handshake message, current state: \(state), role: \(role)", category: SecureLogger.noise, level: .debug)
SecureLogger.debug("NoiseSession[\(peerID)]: Processing handshake message, current state: \(state), role: \(role)")
// Initialize handshake state if needed (for responders)
if state == .uninitialized && role == .responder {
handshakeState = NoiseHandshakeState(
role: role,
pattern: .XX,
keychain: keychain,
localStaticKey: localStaticKey,
remoteStaticKey: nil
)
state = .handshaking
SecureLogger.log("NoiseSession[\(peerID)]: Initialized handshake state for responder", category: SecureLogger.noise, level: .debug)
SecureLogger.debug("NoiseSession[\(peerID)]: Initialized handshake state for responder")
}
guard case .handshaking = state, let handshake = handshakeState else {
@@ -112,12 +98,12 @@ class NoiseSession {
// Process incoming message
_ = try handshake.readMessage(message)
SecureLogger.log("NoiseSession[\(peerID)]: Read handshake message, checking if complete", category: SecureLogger.noise, level: .debug)
SecureLogger.debug("NoiseSession[\(peerID)]: Read handshake message, checking if complete")
// Check if handshake is complete
if handshake.isHandshakeComplete() {
// Get transport ciphers
let (send, receive) = try handshake.getTransportCiphers()
let (send, receive) = try handshake.getTransportCiphers(useExtractedNonce: true)
sendCipher = send
receiveCipher = receive
@@ -130,20 +116,20 @@ class NoiseSession {
state = .established
handshakeState = nil // Clear handshake state
SecureLogger.log("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established", category: SecureLogger.noise, level: .debug)
SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID))
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established")
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
return nil
} else {
// Generate response
let response = try handshake.writeMessage()
sentHandshakeMessages.append(response)
SecureLogger.log("NoiseSession[\(peerID)]: Generated handshake response of size \(response.count)", category: SecureLogger.noise, level: .debug)
SecureLogger.debug("NoiseSession[\(peerID)]: Generated handshake response of size \(response.count)")
// Check if handshake is complete after writing
if handshake.isHandshakeComplete() {
// Get transport ciphers
let (send, receive) = try handshake.getTransportCiphers()
let (send, receive) = try handshake.getTransportCiphers(useExtractedNonce: true)
sendCipher = send
receiveCipher = receive
@@ -156,8 +142,8 @@ class NoiseSession {
state = .established
handshakeState = nil // Clear handshake state
SecureLogger.log("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established", category: SecureLogger.noise, level: .debug)
SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID))
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established")
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
}
return response
@@ -210,12 +196,6 @@ class NoiseSession {
}
}
func getHandshakeHash() -> Data? {
return sessionQueue.sync {
return handshakeHash
}
}
func reset() {
sessionQueue.sync(flags: .barrier) {
let wasEstablished = state == .established
@@ -231,247 +211,19 @@ class NoiseSession {
// Clear sent handshake messages
for i in 0..<sentHandshakeMessages.count {
var message = sentHandshakeMessages[i]
KeychainManager.secureClear(&message)
keychain.secureClear(&message)
}
sentHandshakeMessages.removeAll()
// Clear handshake hash
if var hash = handshakeHash {
KeychainManager.secureClear(&hash)
keychain.secureClear(&hash)
}
handshakeHash = nil
if wasEstablished {
SecureLogger.logSecurityEvent(.sessionExpired(peerID: peerID))
SecureLogger.info(.sessionExpired(peerID: peerID.id))
}
}
}
}
// MARK: - Session Manager
class NoiseSessionManager {
private var sessions: [String: NoiseSession] = [:]
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
// Callbacks
var onSessionEstablished: ((String, Curve25519.KeyAgreement.PublicKey) -> Void)?
var onSessionFailed: ((String, Error) -> Void)?
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey) {
self.localStaticKey = localStaticKey
}
// MARK: - Session Management
func createSession(for peerID: String, role: NoiseRole) -> NoiseSession {
return managerQueue.sync(flags: .barrier) {
let session = SecureNoiseSession(
peerID: peerID,
role: role,
localStaticKey: localStaticKey
)
sessions[peerID] = session
return session
}
}
func getSession(for peerID: String) -> NoiseSession? {
return managerQueue.sync {
return sessions[peerID]
}
}
func removeSession(for peerID: String) {
managerQueue.sync(flags: .barrier) {
if let session = sessions[peerID] {
if session.isEstablished() {
SecureLogger.logSecurityEvent(.sessionExpired(peerID: peerID))
}
// Clear sensitive data before removing
session.reset()
}
_ = sessions.removeValue(forKey: peerID)
}
}
func getEstablishedSessions() -> [String: NoiseSession] {
return managerQueue.sync {
return sessions.filter { $0.value.isEstablished() }
}
}
// MARK: - Handshake Helpers
func initiateHandshake(with peerID: String) throws -> Data {
return try managerQueue.sync(flags: .barrier) {
// Check if we already have an established session
if let existingSession = sessions[peerID], existingSession.isEstablished() {
// Session already established, don't recreate
throw NoiseSessionError.alreadyEstablished
}
// Remove any existing non-established session
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
_ = sessions.removeValue(forKey: peerID)
}
// Create new initiator session
let session = SecureNoiseSession(
peerID: peerID,
role: .initiator,
localStaticKey: localStaticKey
)
sessions[peerID] = session
do {
let handshakeData = try session.startHandshake()
return handshakeData
} catch {
// Clean up failed session
_ = sessions.removeValue(forKey: peerID)
SecureLogger.logSecurityEvent(.handshakeFailed(peerID: peerID, error: error.localizedDescription), level: .error)
throw error
}
}
}
func handleIncomingHandshake(from peerID: String, message: Data) throws -> Data? {
// Process everything within the synchronized block to prevent race conditions
return try managerQueue.sync(flags: .barrier) {
var shouldCreateNew = false
var existingSession: NoiseSession? = nil
if let existing = sessions[peerID] {
// If we have an established session, the peer must have cleared their session
// for a good reason (e.g., decryption failure, restart, etc.)
// We should accept the new handshake to re-establish encryption
if existing.isEstablished() {
SecureLogger.log("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session",
category: SecureLogger.session, level: .info)
_ = sessions.removeValue(forKey: peerID)
shouldCreateNew = true
} else {
// If we're in the middle of a handshake and receive a new initiation,
// reset and start fresh (the other side may have restarted)
if existing.getState() == .handshaking && message.count == 32 {
_ = sessions.removeValue(forKey: peerID)
shouldCreateNew = true
} else {
existingSession = existing
}
}
} else {
shouldCreateNew = true
}
// Get or create session
let session: NoiseSession
if shouldCreateNew {
let newSession = SecureNoiseSession(
peerID: peerID,
role: .responder,
localStaticKey: localStaticKey
)
sessions[peerID] = newSession
session = newSession
} else {
session = existingSession!
}
// Process the handshake message within the synchronized block
do {
let response = try session.processHandshakeMessage(message)
// Check if session is established after processing
if session.isEstablished() {
if let remoteKey = session.getRemoteStaticPublicKey() {
// Schedule callback outside the synchronized block to prevent deadlock
DispatchQueue.global().async { [weak self] in
self?.onSessionEstablished?(peerID, remoteKey)
}
}
}
return response
} catch {
// Reset the session on handshake failure so next attempt can start fresh
_ = sessions.removeValue(forKey: peerID)
// Schedule callback outside the synchronized block to prevent deadlock
DispatchQueue.global().async { [weak self] in
self?.onSessionFailed?(peerID, error)
}
SecureLogger.logSecurityEvent(.handshakeFailed(peerID: peerID, error: error.localizedDescription), level: .error)
throw error
}
}
}
// MARK: - Encryption/Decryption
func encrypt(_ plaintext: Data, for peerID: String) throws -> Data {
guard let session = getSession(for: peerID) else {
throw NoiseSessionError.sessionNotFound
}
return try session.encrypt(plaintext)
}
func decrypt(_ ciphertext: Data, from peerID: String) throws -> Data {
guard let session = getSession(for: peerID) else {
throw NoiseSessionError.sessionNotFound
}
return try session.decrypt(ciphertext)
}
// MARK: - Key Management
func getRemoteStaticKey(for peerID: String) -> Curve25519.KeyAgreement.PublicKey? {
return getSession(for: peerID)?.getRemoteStaticPublicKey()
}
func getHandshakeHash(for peerID: String) -> Data? {
return getSession(for: peerID)?.getHandshakeHash()
}
// MARK: - Session Rekeying
func getSessionsNeedingRekey() -> [(peerID: String, needsRekey: Bool)] {
return managerQueue.sync {
var needingRekey: [(peerID: String, needsRekey: Bool)] = []
for (peerID, session) in sessions {
if let secureSession = session as? SecureNoiseSession,
secureSession.isEstablished(),
secureSession.needsRenegotiation() {
needingRekey.append((peerID: peerID, needsRekey: true))
}
}
return needingRekey
}
}
func initiateRekey(for peerID: String) throws {
// Remove old session
removeSession(for: peerID)
// Initiate new handshake
_ = try initiateHandshake(with: peerID)
}
}
// MARK: - Errors
enum NoiseSessionError: Error {
case invalidState
case notEstablished
case sessionNotFound
case handshakeFailed(Error)
case alreadyEstablished
}
+14
View File
@@ -0,0 +1,14 @@
//
// NoiseSessionError.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
enum NoiseSessionError: Error, Equatable {
case invalidState
case notEstablished
case sessionNotFound
case alreadyEstablished
}
+211
View File
@@ -0,0 +1,211 @@
//
// NoiseSessionManager.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitLogger
import CryptoKit
import Foundation
final class NoiseSessionManager {
private var sessions: [PeerID: NoiseSession] = [:]
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
private let keychain: KeychainManagerProtocol
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
// Callbacks
var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey) -> Void)?
var onSessionFailed: ((PeerID, Error) -> Void)?
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
self.localStaticKey = localStaticKey
self.keychain = keychain
}
// MARK: - Session Management
func getSession(for peerID: PeerID) -> NoiseSession? {
return managerQueue.sync {
return sessions[peerID]
}
}
func removeSession(for peerID: PeerID) {
managerQueue.sync(flags: .barrier) {
if let session = sessions.removeValue(forKey: peerID) {
session.reset() // Clear sensitive data before removing
}
}
}
func removeAllSessions() {
managerQueue.sync(flags: .barrier) {
for (_, session) in sessions {
session.reset()
}
sessions.removeAll()
}
}
// MARK: - Handshake Helpers
func initiateHandshake(with peerID: PeerID) throws -> Data {
return try managerQueue.sync(flags: .barrier) {
// Check if we already have an established session
if let existingSession = sessions[peerID], existingSession.isEstablished() {
// Session already established, don't recreate
throw NoiseSessionError.alreadyEstablished
}
// Remove any existing non-established session
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
_ = sessions.removeValue(forKey: peerID)
}
// Create new initiator session
let session = SecureNoiseSession(
peerID: peerID,
role: .initiator,
keychain: keychain,
localStaticKey: localStaticKey
)
sessions[peerID] = session
do {
let handshakeData = try session.startHandshake()
return handshakeData
} catch {
// Clean up failed session
_ = sessions.removeValue(forKey: peerID)
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
throw error
}
}
}
func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
// Process everything within the synchronized block to prevent race conditions
return try managerQueue.sync(flags: .barrier) {
var shouldCreateNew = false
var existingSession: NoiseSession? = nil
if let existing = sessions[peerID] {
// If we have an established session, the peer must have cleared their session
// for a good reason (e.g., decryption failure, restart, etc.)
// We should accept the new handshake to re-establish encryption
if existing.isEstablished() {
SecureLogger.info("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session", category: .session)
_ = sessions.removeValue(forKey: peerID)
shouldCreateNew = true
} else {
// If we're in the middle of a handshake and receive a new initiation,
// reset and start fresh (the other side may have restarted)
if existing.getState() == .handshaking && message.count == 32 {
_ = sessions.removeValue(forKey: peerID)
shouldCreateNew = true
} else {
existingSession = existing
}
}
} else {
shouldCreateNew = true
}
// Get or create session
let session: NoiseSession
if shouldCreateNew {
let newSession = SecureNoiseSession(
peerID: peerID,
role: .responder,
keychain: keychain,
localStaticKey: localStaticKey
)
sessions[peerID] = newSession
session = newSession
} else {
session = existingSession!
}
// Process the handshake message within the synchronized block
do {
let response = try session.processHandshakeMessage(message)
// Check if session is established after processing
if session.isEstablished() {
if let remoteKey = session.getRemoteStaticPublicKey() {
// Schedule callback outside the synchronized block to prevent deadlock
DispatchQueue.global().async { [weak self] in
self?.onSessionEstablished?(peerID, remoteKey)
}
}
}
return response
} catch {
// Reset the session on handshake failure so next attempt can start fresh
_ = sessions.removeValue(forKey: peerID)
// Schedule callback outside the synchronized block to prevent deadlock
DispatchQueue.global().async { [weak self] in
self?.onSessionFailed?(peerID, error)
}
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
throw error
}
}
}
// MARK: - Encryption/Decryption
func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data {
guard let session = getSession(for: peerID) else {
throw NoiseSessionError.sessionNotFound
}
return try session.encrypt(plaintext)
}
func decrypt(_ ciphertext: Data, from peerID: PeerID) throws -> Data {
guard let session = getSession(for: peerID) else {
throw NoiseSessionError.sessionNotFound
}
return try session.decrypt(ciphertext)
}
// MARK: - Key Management
func getRemoteStaticKey(for peerID: PeerID) -> Curve25519.KeyAgreement.PublicKey? {
return getSession(for: peerID)?.getRemoteStaticPublicKey()
}
// MARK: - Session Rekeying
func getSessionsNeedingRekey() -> [(peerID: PeerID, needsRekey: Bool)] {
return managerQueue.sync {
var needingRekey: [(peerID: PeerID, needsRekey: Bool)] = []
for (peerID, session) in sessions {
if let secureSession = session as? SecureNoiseSession,
secureSession.isEstablished(),
secureSession.needsRenegotiation() {
needingRekey.append((peerID: peerID, needsRekey: true))
}
}
return needingRekey
}
}
func initiateRekey(for peerID: PeerID) throws {
// Remove old session
removeSession(for: peerID)
// Initiate new handshake
_ = try initiateHandshake(with: peerID)
}
}
+13
View File
@@ -0,0 +1,13 @@
//
// NoiseSessionState.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
enum NoiseSessionState: Equatable {
case uninitialized
case handshaking
case established
}
+81
View File
@@ -0,0 +1,81 @@
//
// SecureNoiseSession.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
final class SecureNoiseSession: NoiseSession {
private(set) var messageCount: UInt64 = 0
private let sessionStartTime = Date()
private(set) var lastActivityTime = Date()
override func encrypt(_ plaintext: Data) throws -> Data {
// Check session age
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
throw NoiseSecurityError.sessionExpired
}
// Check message count
if messageCount >= NoiseSecurityConstants.maxMessagesPerSession {
throw NoiseSecurityError.sessionExhausted
}
// Validate message size
guard NoiseSecurityValidator.validateMessageSize(plaintext) else {
throw NoiseSecurityError.messageTooLarge
}
let encrypted = try super.encrypt(plaintext)
messageCount += 1
lastActivityTime = Date()
return encrypted
}
override func decrypt(_ ciphertext: Data) throws -> Data {
// Check session age
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
throw NoiseSecurityError.sessionExpired
}
// Validate message size
guard NoiseSecurityValidator.validateMessageSize(ciphertext) else {
throw NoiseSecurityError.messageTooLarge
}
let decrypted = try super.decrypt(ciphertext)
lastActivityTime = Date()
return decrypted
}
func needsRenegotiation() -> Bool {
// Check if we've used more than 90% of message limit
let messageThreshold = UInt64(Double(NoiseSecurityConstants.maxMessagesPerSession) * 0.9)
if messageCount >= messageThreshold {
return true
}
// Check if last activity was more than 30 minutes ago
if Date().timeIntervalSince(lastActivityTime) > NoiseSecurityConstants.sessionTimeout {
return true
}
return false
}
// MARK: - Testing Support
#if DEBUG
func setLastActivityTimeForTesting(_ date: Date) {
lastActivityTime = date
}
func setMessageCountForTesting(_ count: UInt64) {
messageCount = count
}
#endif
}
+135
View File
@@ -0,0 +1,135 @@
import Foundation
/// Bech32 encoding for Nostr (minimal implementation)
enum Bech32 {
private static let charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
private static let generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
static func encode(hrp: String, data: Data) throws -> String {
let values = convertBits(from: 8, to: 5, pad: true, data: Array(data))
let checksum = createChecksum(hrp: hrp, values: values)
let combined = values + checksum
return hrp + "1" + combined.map {
let index = charset.index(charset.startIndex, offsetBy: Int($0))
return String(charset[index])
}.joined()
}
static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) {
// Find the last occurrence of '1'
guard let separatorIndex = bech32String.lastIndex(of: "1") else {
throw Bech32Error.invalidFormat
}
let hrp = String(bech32String[..<separatorIndex])
// Validate HRP contains only ASCII characters
for char in hrp {
guard char.asciiValue != nil else {
throw Bech32Error.invalidCharacter
}
}
let dataString = String(bech32String[bech32String.index(after: separatorIndex)...])
// Convert characters to values
var values = [UInt8]()
for char in dataString {
guard let index = charset.firstIndex(of: char) else {
throw Bech32Error.invalidCharacter
}
values.append(UInt8(charset.distance(from: charset.startIndex, to: index)))
}
// Verify checksum
guard values.count >= 6 else {
throw Bech32Error.invalidChecksum
}
let payloadValues = Array(values.dropLast(6))
let checksum = Array(values.suffix(6))
let expectedChecksum = createChecksum(hrp: hrp, values: payloadValues)
guard checksum == expectedChecksum else {
throw Bech32Error.invalidChecksum
}
// Convert back to bytes
let bytes = convertBits(from: 5, to: 8, pad: false, data: payloadValues)
return (hrp: hrp, data: Data(bytes))
}
enum Bech32Error: Error {
case invalidFormat
case invalidCharacter
case invalidChecksum
}
private static func convertBits(from: Int, to: Int, pad: Bool, data: [UInt8]) -> [UInt8] {
var acc = 0
var bits = 0
var result = [UInt8]()
let maxv = (1 << to) - 1
for value in data {
acc = (acc << from) | Int(value)
bits += from
while bits >= to {
bits -= to
result.append(UInt8((acc >> bits) & maxv))
}
}
if pad && bits > 0 {
result.append(UInt8((acc << (to - bits)) & maxv))
}
return result
}
private static func createChecksum(hrp: String, values: [UInt8]) -> [UInt8] {
let checksumValues = hrpExpand(hrp) + values + [0, 0, 0, 0, 0, 0]
let polymod = polymod(checksumValues) ^ 1
var checksum = [UInt8]()
for i in 0..<6 {
checksum.append(UInt8((polymod >> (5 * (5 - i))) & 31))
}
return checksum
}
private static func hrpExpand(_ hrp: String) -> [UInt8] {
var result = [UInt8]()
for c in hrp {
guard let asciiValue = c.asciiValue else {
return [] // Return empty array for invalid input
}
result.append(UInt8(asciiValue >> 5))
}
result.append(0)
for c in hrp {
guard let asciiValue = c.asciiValue else {
return [] // Return empty array for invalid input
}
result.append(UInt8(asciiValue & 31))
}
return result
}
private static func polymod(_ values: [UInt8]) -> Int {
var chk = 1
for value in values {
let b = chk >> 25
chk = (chk & 0x1ffffff) << 5 ^ Int(value)
for i in 0..<5 {
if (b >> i) & 1 == 1 {
chk ^= generator[i]
}
}
}
return chk
}
}
+226 -33
View File
@@ -1,4 +1,11 @@
import BitLogger
import Foundation
import Tor
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
@MainActor
@@ -10,19 +17,32 @@ final class GeoRelayDirectory {
}
static let shared = GeoRelayDirectory()
private(set) var entries: [Entry] = []
private let cacheFileName = "georelays_cache.csv"
private let lastFetchKey = "georelay.lastFetchAt"
private let remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!
private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds // 24h
private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds
private var refreshTimer: Timer?
private var retryTask: Task<Void, Never>?
private var retryAttempt: Int = 0
private var isFetching: Bool = false
private var observers: [NSObjectProtocol] = []
private init() {
// Load cached or bundled data synchronously
self.entries = self.loadLocalEntries()
// Fire-and-forget remote refresh if stale
entries = loadLocalEntries()
registerObservers()
startRefreshTimer()
prefetchIfNeeded()
}
deinit {
observers.forEach { NotificationCenter.default.removeObserver($0) }
refreshTimer?.invalidate()
retryTask?.cancel()
}
/// Returns up to `count` relay URLs (wss://) closest to the geohash center.
func closestRelays(toGeohash geohash: String, count: Int = 5) -> [String] {
let center = Geohash.decodeCenter(geohash)
@@ -31,42 +51,146 @@ final class GeoRelayDirectory {
/// Returns up to `count` relay URLs (wss://) closest to the given coordinate.
func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {
guard !entries.isEmpty else { return [] }
let sorted = entries
.sorted { a, b in
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
guard !entries.isEmpty, count > 0 else { return [] }
if entries.count <= count {
return entries
.sorted { a, b in
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
}
.map { "wss://\($0.host)" }
}
var best: [(entry: Entry, distance: Double)] = []
best.reserveCapacity(count)
for entry in entries {
let distance = haversineKm(lat, lon, entry.lat, entry.lon)
if best.count < count {
let idx = best.firstIndex { $0.distance > distance } ?? best.count
best.insert((entry, distance), at: idx)
} else if let worstDistance = best.last?.distance, distance < worstDistance {
let idx = best.firstIndex { $0.distance > distance } ?? best.count
best.insert((entry, distance), at: idx)
best.removeLast()
}
.prefix(count)
return sorted.map { "wss://\($0.host)" }
}
return best.map { "wss://\($0.entry.host)" }
}
// MARK: - Remote Fetch
func prefetchIfNeeded() {
func prefetchIfNeeded(force: Bool = false) {
guard !isFetching else { return }
let now = Date()
let last = UserDefaults.standard.object(forKey: lastFetchKey) as? Date ?? .distantPast
guard now.timeIntervalSince(last) >= fetchInterval else { return }
if !force {
guard now.timeIntervalSince(last) >= fetchInterval else { return }
} else if last != .distantPast,
now.timeIntervalSince(last) < TransportConfig.geoRelayRetryInitialSeconds {
// Skip forced fetches if we just refreshed moments ago.
return
}
cancelRetry()
fetchRemote()
}
private func fetchRemote() {
let req = URLRequest(url: remoteURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
let task = URLSession.shared.dataTask(with: req) { [weak self] data, _, error in
guard let self = self else { return }
if let data = data, error == nil, let text = String(data: data, encoding: .utf8) {
let parsed = GeoRelayDirectory.parseCSV(text)
if !parsed.isEmpty {
Task { @MainActor in
self.entries = parsed
self.persistCache(text)
UserDefaults.standard.set(Date(), forKey: self.lastFetchKey)
SecureLogger.log("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: SecureLogger.session, level: .info)
}
guard !isFetching else { return }
isFetching = true
let request = URLRequest(
url: remoteURL,
cachePolicy: .reloadIgnoringLocalCacheData,
timeoutInterval: 15
)
Task.detached { [weak self] in
guard let self else { return }
let ready = await TorManager.shared.awaitReady()
if !ready {
await self.handleFetchFailure(.torNotReady)
return
}
do {
let (data, _) = try await TorURLSession.shared.session.data(for: request)
guard let text = String(data: data, encoding: .utf8) else {
await self.handleFetchFailure(.invalidData)
return
}
let parsed = GeoRelayDirectory.parseCSV(text)
guard !parsed.isEmpty else {
await self.handleFetchFailure(.invalidData)
return
}
await self.handleFetchSuccess(entries: parsed, csv: text)
} catch {
await self.handleFetchFailure(.network(error))
}
SecureLogger.log("GeoRelayDirectory: remote fetch failed; keeping local entries", category: SecureLogger.session, level: .warning)
}
task.resume()
}
private enum FetchFailure {
case torNotReady
case invalidData
case network(Error)
}
@MainActor
private func handleFetchSuccess(entries parsed: [Entry], csv: String) {
entries = parsed
persistCache(csv)
UserDefaults.standard.set(Date(), forKey: lastFetchKey)
SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
isFetching = false
retryAttempt = 0
cancelRetry()
}
@MainActor
private func handleFetchFailure(_ reason: FetchFailure) {
switch reason {
case .torNotReady:
SecureLogger.warning("GeoRelayDirectory: Tor not ready; scheduling retry", category: .session)
case .invalidData:
SecureLogger.warning("GeoRelayDirectory: remote fetch returned invalid data; scheduling retry", category: .session)
case .network(let error):
SecureLogger.warning("GeoRelayDirectory: remote fetch failed with error: \(error.localizedDescription)", category: .session)
}
isFetching = false
scheduleRetry()
}
@MainActor
private func scheduleRetry() {
retryAttempt = min(retryAttempt + 1, 10)
let base = TransportConfig.geoRelayRetryInitialSeconds
let maxDelay = TransportConfig.geoRelayRetryMaxSeconds
let multiplier = pow(2.0, Double(max(retryAttempt - 1, 0)))
let calculated = base * multiplier
let delay = min(maxDelay, max(base, calculated))
cancelRetry()
retryTask = Task { [weak self] in
let nanoseconds = UInt64(delay * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanoseconds)
await MainActor.run {
self?.prefetchIfNeeded(force: true)
}
}
}
@MainActor
private func cancelRetry() {
retryTask?.cancel()
retryTask = nil
}
private func persistCache(_ text: String) {
@@ -74,45 +198,49 @@ final class GeoRelayDirectory {
do {
try text.data(using: .utf8)?.write(to: url, options: .atomic)
} catch {
SecureLogger.log("GeoRelayDirectory: failed to write cache: \(error)", category: SecureLogger.session, level: .warning)
SecureLogger.warning("GeoRelayDirectory: failed to write cache: \(error)", category: .session)
}
}
// MARK: - Loading
private func loadLocalEntries() -> [Entry] {
// Prefer cached file if present
if let cache = self.cacheURL(),
if let cache = cacheURL(),
let data = try? Data(contentsOf: cache),
let text = String(data: data, encoding: .utf8) {
let arr = Self.parseCSV(text)
if !arr.isEmpty { return arr }
}
// Try bundled resource(s)
let bundleCandidates = [
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
].compactMap { $0 }
for url in bundleCandidates {
if let data = try? Data(contentsOf: url), let text = String(data: data, encoding: .utf8) {
if let data = try? Data(contentsOf: url),
let text = String(data: data, encoding: .utf8) {
let arr = Self.parseCSV(text)
if !arr.isEmpty { return arr }
}
}
// Try filesystem path (development/test)
if let cwd = FileManager.default.currentDirectoryPath as String?,
let data = try? Data(contentsOf: URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
let text = String(data: data, encoding: .utf8) {
return Self.parseCSV(text)
}
SecureLogger.log("GeoRelayDirectory: no local CSV found; entries empty", category: SecureLogger.session, level: .warning)
SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session)
return []
}
nonisolated static func parseCSV(_ text: String) -> [Entry] {
var result: Set<Entry> = []
let lines = text.split(whereSeparator: { $0.isNewline })
// Skip header if present
for (idx, raw) in lines.enumerated() {
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if line.isEmpty { continue }
@@ -133,11 +261,76 @@ final class GeoRelayDirectory {
private func cacheURL() -> URL? {
do {
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let base = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir.appendingPathComponent(cacheFileName)
} catch { return nil }
} catch {
return nil
}
}
// MARK: - Observers & Timers
private func registerObservers() {
let center = NotificationCenter.default
let torReady = center.addObserver(
forName: .TorDidBecomeReady,
object: nil,
queue: .main
) { [weak self] _ in
guard let self else { return }
Task { @MainActor in
self.prefetchIfNeeded(force: true)
}
}
observers.append(torReady)
#if os(iOS)
let didBecomeActive = center.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { [weak self] _ in
guard let self else { return }
Task { @MainActor in
self.prefetchIfNeeded()
}
}
observers.append(didBecomeActive)
#elseif os(macOS)
let didBecomeActive = center.addObserver(
forName: NSApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { [weak self] _ in
guard let self else { return }
Task { @MainActor in
self.prefetchIfNeeded()
}
}
observers.append(didBecomeActive)
#endif
}
private func startRefreshTimer() {
refreshTimer?.invalidate()
let interval = TransportConfig.geoRelayRefreshCheckIntervalSeconds
guard interval > 0 else { return }
let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
guard let self else { return }
Task { @MainActor in
self.prefetchIfNeeded()
}
}
refreshTimer = timer
RunLoop.main.add(timer, forMode: .common)
}
}
+50
View File
@@ -0,0 +1,50 @@
import Foundation
protocol KeychainHelperProtocol {
func save(key: String, data: Data, service: String, accessible: CFString?)
func load(key: String, service: String) -> Data?
func delete(key: String, service: String)
}
/// Keychain helper for secure storage
struct KeychainHelper: KeychainHelperProtocol {
func save(key: String, data: Data, service: String, accessible: CFString? = nil) {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecValueData as String: data
]
if let accessible = accessible {
query[kSecAttrAccessible as String] = accessible
}
SecItemDelete(query as CFDictionary)
SecItemAdd(query as CFDictionary, nil)
}
func load(key: String, service: String) -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess else { return nil }
return result as? Data
}
func delete(key: String, service: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
}
}
+15 -15
View File
@@ -4,7 +4,7 @@ import Foundation
struct NostrEmbeddedBitChat {
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a private message for Nostr DMs.
static func encodePMForNostr(content: String, messageID: String, recipientPeerID: String, senderPeerID: String) -> String? {
static func encodePMForNostr(content: String, messageID: String, recipientPeerID: PeerID, senderPeerID: PeerID) -> String? {
// TLV-encode the private message
let pm = PrivateMessagePacket(messageID: messageID, content: content)
guard let tlv = pm.encode() else { return nil }
@@ -14,12 +14,12 @@ struct NostrEmbeddedBitChat {
payload.append(tlv)
// Determine 8-byte recipient ID to embed
let recipientIDHex: String = normalizeRecipientPeerID(recipientPeerID)
let recipientID = normalizeRecipientPeerID(recipientPeerID)
let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: senderPeerID) ?? Data(),
recipientID: Data(hexString: recipientIDHex),
senderID: Data(hexString: senderPeerID.id) ?? Data(),
recipientID: Data(hexString: recipientID.id),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
@@ -31,18 +31,18 @@ struct NostrEmbeddedBitChat {
}
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a delivery/read ack for Nostr DMs.
static func encodeAckForNostr(type: NoisePayloadType, messageID: String, recipientPeerID: String, senderPeerID: String) -> String? {
static func encodeAckForNostr(type: NoisePayloadType, messageID: String, recipientPeerID: PeerID, senderPeerID: PeerID) -> String? {
guard type == .delivered || type == .readReceipt else { return nil }
var payload = Data([type.rawValue])
payload.append(Data(messageID.utf8))
let recipientIDHex: String = normalizeRecipientPeerID(recipientPeerID)
let recipientID = normalizeRecipientPeerID(recipientPeerID)
let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: senderPeerID) ?? Data(),
recipientID: Data(hexString: recipientIDHex),
senderID: Data(hexString: senderPeerID.id) ?? Data(),
recipientID: Data(hexString: recipientID.id),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
@@ -54,7 +54,7 @@ struct NostrEmbeddedBitChat {
}
/// Build a `bitchat1:` ACK (delivered/read) without an embedded recipient peer ID (geohash DMs).
static func encodeAckForNostrNoRecipient(type: NoisePayloadType, messageID: String, senderPeerID: String) -> String? {
static func encodeAckForNostrNoRecipient(type: NoisePayloadType, messageID: String, senderPeerID: PeerID) -> String? {
guard type == .delivered || type == .readReceipt else { return nil }
var payload = Data([type.rawValue])
@@ -62,7 +62,7 @@ struct NostrEmbeddedBitChat {
let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: senderPeerID) ?? Data(),
senderID: Data(hexString: senderPeerID.id) ?? Data(),
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
@@ -75,7 +75,7 @@ struct NostrEmbeddedBitChat {
}
/// Build a `bitchat1:` payload without an embedded recipient peer ID (used for geohash DMs).
static func encodePMForNostrNoRecipient(content: String, messageID: String, senderPeerID: String) -> String? {
static func encodePMForNostrNoRecipient(content: String, messageID: String, senderPeerID: PeerID) -> String? {
let pm = PrivateMessagePacket(messageID: messageID, content: content)
guard let tlv = pm.encode() else { return nil }
@@ -84,7 +84,7 @@ struct NostrEmbeddedBitChat {
let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: senderPeerID) ?? Data(),
senderID: Data(hexString: senderPeerID.id) ?? Data(),
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
@@ -96,11 +96,11 @@ struct NostrEmbeddedBitChat {
return "bitchat1:" + base64URLEncode(data)
}
private static func normalizeRecipientPeerID(_ recipientPeerID: String) -> String {
if let maybeData = Data(hexString: recipientPeerID) {
private static func normalizeRecipientPeerID(_ recipientPeerID: PeerID) -> PeerID {
if let maybeData = Data(hexString: recipientPeerID.id) {
if maybeData.count == 32 {
// Treat as Noise static public key; derive peerID from fingerprint
return PeerIDUtils.derivePeerID(fromPublicKey: maybeData)
return PeerID(publicKey: maybeData)
} else if maybeData.count == 8 {
// Already an 8-byte peer ID
return recipientPeerID
-293
View File
@@ -1,50 +1,5 @@
import Foundation
import CryptoKit
import P256K
import Security
// Keychain helper for secure storage
struct KeychainHelper {
static func save(key: String, data: Data, service: String, accessible: CFString? = nil) {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecValueData as String: data
]
if let accessible = accessible {
query[kSecAttrAccessible as String] = accessible
}
SecItemDelete(query as CFDictionary)
SecItemAdd(query as CFDictionary, nil)
}
static func load(key: String, service: String) -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess else { return nil }
return result as? Data
}
static func delete(key: String, service: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
}
}
/// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging
struct NostrIdentity: Codable {
@@ -103,251 +58,3 @@ struct NostrIdentity: Codable {
return publicKey.hexEncodedString()
}
}
/// Bridge between Noise and Nostr identities
struct NostrIdentityBridge {
private static let keychainService = "chat.bitchat.nostr"
private static let currentIdentityKey = "nostr-current-identity"
private static let deviceSeedKey = "nostr-device-seed"
// In-memory cache to avoid transient keychain access issues
private static var deviceSeedCache: Data?
/// Get or create the current Nostr identity
static func getCurrentNostrIdentity() throws -> NostrIdentity? {
// Check if we already have a Nostr identity
if let existingData = KeychainHelper.load(key: currentIdentityKey, service: keychainService),
let identity = try? JSONDecoder().decode(NostrIdentity.self, from: existingData) {
return identity
}
// Generate new Nostr identity
let nostrIdentity = try NostrIdentity.generate()
// Store it
let data = try JSONEncoder().encode(nostrIdentity)
KeychainHelper.save(key: currentIdentityKey, data: data, service: keychainService)
return nostrIdentity
}
/// Associate a Nostr identity with a Noise public key (for favorites)
static func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
if let data = nostrPubkey.data(using: .utf8) {
KeychainHelper.save(key: key, data: data, service: keychainService)
}
}
/// Get Nostr public key associated with a Noise public key
static func getNostrPublicKey(for noisePublicKey: Data) -> String? {
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
guard let data = KeychainHelper.load(key: key, service: keychainService),
let pubkey = String(data: data, encoding: .utf8) else {
return nil
}
return pubkey
}
/// Clear all Nostr identity associations and current identity
static func clearAllAssociations() {
// Delete current Nostr identity
KeychainHelper.delete(key: currentIdentityKey, service: keychainService)
KeychainHelper.delete(key: deviceSeedKey, service: keychainService)
// Note: We can't efficiently delete all noise-nostr associations
// without tracking them, but they'll be orphaned and eventually cleaned up
// The important part is deleting the current identity so a new one is generated
}
// MARK: - Per-Geohash Identities (Location Channels)
/// Returns a stable device seed used to derive unlinkable per-geohash identities.
/// Stored only on device keychain.
private static func getOrCreateDeviceSeed() -> Data {
if let cached = deviceSeedCache { return cached }
if let existing = KeychainHelper.load(key: deviceSeedKey, service: keychainService) {
// Migrate to AfterFirstUnlockThisDeviceOnly for stability during lock
KeychainHelper.save(key: deviceSeedKey, data: existing, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
deviceSeedCache = existing
return existing
}
var seed = Data(count: 32)
_ = seed.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
}
// Ensure availability after first unlock to prevent unintended rotation when locked
KeychainHelper.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
deviceSeedCache = seed
return seed
}
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
/// if the candidate is not a valid secp256k1 private key.
static func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
let seed = getOrCreateDeviceSeed()
guard let msg = geohash.data(using: .utf8) else {
throw NSError(domain: "NostrIdentity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid geohash string"])
}
func candidateKey(iteration: UInt32) -> Data {
var input = Data(msg)
var iterBE = iteration.bigEndian
withUnsafeBytes(of: &iterBE) { bytes in
input.append(contentsOf: bytes)
}
let code = CryptoKit.HMAC<CryptoKit.SHA256>.authenticationCode(for: input, using: SymmetricKey(data: seed))
return Data(code)
}
// Try a few iterations to ensure a valid key can be formed
for i in 0..<10 {
let keyData = candidateKey(iteration: UInt32(i))
if let identity = try? NostrIdentity(privateKeyData: keyData) {
return identity
}
}
// As a final fallback, hash the seed+msg and try again
var combined = Data()
combined.append(seed)
combined.append(msg)
let fallback = Data(CryptoKit.SHA256.hash(data: combined))
return try NostrIdentity(privateKeyData: fallback)
}
}
// Bech32 encoding for Nostr (minimal implementation)
enum Bech32 {
private static let charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
private static let generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
static func encode(hrp: String, data: Data) throws -> String {
let values = convertBits(from: 8, to: 5, pad: true, data: Array(data))
let checksum = createChecksum(hrp: hrp, values: values)
let combined = values + checksum
return hrp + "1" + combined.map {
let index = charset.index(charset.startIndex, offsetBy: Int($0))
return String(charset[index])
}.joined()
}
static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) {
// Find the last occurrence of '1'
guard let separatorIndex = bech32String.lastIndex(of: "1") else {
throw Bech32Error.invalidFormat
}
let hrp = String(bech32String[..<separatorIndex])
// Validate HRP contains only ASCII characters
for char in hrp {
guard char.asciiValue != nil else {
throw Bech32Error.invalidCharacter
}
}
let dataString = String(bech32String[bech32String.index(after: separatorIndex)...])
// Convert characters to values
var values = [UInt8]()
for char in dataString {
guard let index = charset.firstIndex(of: char) else {
throw Bech32Error.invalidCharacter
}
values.append(UInt8(charset.distance(from: charset.startIndex, to: index)))
}
// Verify checksum
guard values.count >= 6 else {
throw Bech32Error.invalidChecksum
}
let payloadValues = Array(values.dropLast(6))
let checksum = Array(values.suffix(6))
let expectedChecksum = createChecksum(hrp: hrp, values: payloadValues)
guard checksum == expectedChecksum else {
throw Bech32Error.invalidChecksum
}
// Convert back to bytes
let bytes = convertBits(from: 5, to: 8, pad: false, data: payloadValues)
return (hrp: hrp, data: Data(bytes))
}
enum Bech32Error: Error {
case invalidFormat
case invalidCharacter
case invalidChecksum
}
private static func convertBits(from: Int, to: Int, pad: Bool, data: [UInt8]) -> [UInt8] {
var acc = 0
var bits = 0
var result = [UInt8]()
let maxv = (1 << to) - 1
for value in data {
acc = (acc << from) | Int(value)
bits += from
while bits >= to {
bits -= to
result.append(UInt8((acc >> bits) & maxv))
}
}
if pad && bits > 0 {
result.append(UInt8((acc << (to - bits)) & maxv))
}
return result
}
private static func createChecksum(hrp: String, values: [UInt8]) -> [UInt8] {
let checksumValues = hrpExpand(hrp) + values + [0, 0, 0, 0, 0, 0]
let polymod = polymod(checksumValues) ^ 1
var checksum = [UInt8]()
for i in 0..<6 {
checksum.append(UInt8((polymod >> (5 * (5 - i))) & 31))
}
return checksum
}
private static func hrpExpand(_ hrp: String) -> [UInt8] {
var result = [UInt8]()
for c in hrp {
guard let asciiValue = c.asciiValue else {
return [] // Return empty array for invalid input
}
result.append(UInt8(asciiValue >> 5))
}
result.append(0)
for c in hrp {
guard let asciiValue = c.asciiValue else {
return [] // Return empty array for invalid input
}
result.append(UInt8(asciiValue & 31))
}
return result
}
private static func polymod(_ values: [UInt8]) -> Int {
var chk = 1
for value in values {
let b = chk >> 25
chk = (chk & 0x1ffffff) << 5 ^ Int(value)
for i in 0..<5 {
if (b >> i) & 1 == 1 {
chk ^= generator[i]
}
}
}
return chk
}
}
// Data hex encoding extension moved to BinaryEncodingUtils.swift to avoid duplication
+157
View File
@@ -0,0 +1,157 @@
import Foundation
import CryptoKit
/// Bridge between Noise and Nostr identities
final class NostrIdentityBridge {
private let keychainService = "chat.bitchat.nostr"
private let currentIdentityKey = "nostr-current-identity"
private let deviceSeedKey = "nostr-device-seed"
// In-memory cache to avoid transient keychain access issues
private var deviceSeedCache: Data?
// Cache derived identities to avoid repeated crypto during view rendering
private var derivedIdentityCache: [String: NostrIdentity] = [:]
private let cacheLock = NSLock()
private let keychain: KeychainHelperProtocol
init(keychain: KeychainHelperProtocol = KeychainHelper()) {
self.keychain = keychain
}
/// Get or create the current Nostr identity
func getCurrentNostrIdentity() throws -> NostrIdentity? {
// Check if we already have a Nostr identity
if let existingData = keychain.load(key: currentIdentityKey, service: keychainService),
let identity = try? JSONDecoder().decode(NostrIdentity.self, from: existingData) {
return identity
}
// Generate new Nostr identity
let nostrIdentity = try NostrIdentity.generate()
// Store it
let data = try JSONEncoder().encode(nostrIdentity)
keychain.save(key: currentIdentityKey, data: data, service: keychainService, accessible: nil)
return nostrIdentity
}
/// Associate a Nostr identity with a Noise public key (for favorites)
func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
if let data = nostrPubkey.data(using: .utf8) {
keychain.save(key: key, data: data, service: keychainService, accessible: nil)
}
}
/// Get Nostr public key associated with a Noise public key
func getNostrPublicKey(for noisePublicKey: Data) -> String? {
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
guard let data = keychain.load(key: key, service: keychainService),
let pubkey = String(data: data, encoding: .utf8) else {
return nil
}
return pubkey
}
/// Clear all Nostr identity associations and current identity
func clearAllAssociations() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecMatchLimit as String: kSecMatchLimitAll,
kSecReturnAttributes as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecSuccess, let items = result as? [[String: Any]] {
for item in items {
var deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService
]
if let account = item[kSecAttrAccount as String] as? String {
deleteQuery[kSecAttrAccount as String] = account
}
SecItemDelete(deleteQuery as CFDictionary)
}
} else if status == errSecItemNotFound {
// nothing persisted; no action needed
}
deviceSeedCache = nil
}
// MARK: - Per-Geohash Identities (Location Channels)
/// Returns a stable device seed used to derive unlinkable per-geohash identities.
/// Stored only on device keychain.
private func getOrCreateDeviceSeed() -> Data {
if let cached = deviceSeedCache { return cached }
if let existing = keychain.load(key: deviceSeedKey, service: keychainService) {
// Migrate to AfterFirstUnlockThisDeviceOnly for stability during lock
keychain.save(key: deviceSeedKey, data: existing, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
deviceSeedCache = existing
return existing
}
var seed = Data(count: 32)
_ = seed.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
}
// Ensure availability after first unlock to prevent unintended rotation when locked
keychain.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
deviceSeedCache = seed
return seed
}
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
/// if the candidate is not a valid secp256k1 private key.
func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
// Check cache first to avoid repeated crypto + keychain I/O during view rendering
cacheLock.lock()
if let cached = derivedIdentityCache[geohash] {
cacheLock.unlock()
return cached
}
cacheLock.unlock()
let seed = getOrCreateDeviceSeed()
guard let msg = geohash.data(using: .utf8) else {
throw NSError(domain: "NostrIdentity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid geohash string"])
}
func candidateKey(iteration: UInt32) -> Data {
var input = Data(msg)
var iterBE = iteration.bigEndian
withUnsafeBytes(of: &iterBE) { bytes in
input.append(contentsOf: bytes)
}
let code = HMAC<SHA256>.authenticationCode(for: input, using: SymmetricKey(data: seed))
return Data(code)
}
// Try a few iterations to ensure a valid key can be formed
for i in 0..<10 {
let keyData = candidateKey(iteration: UInt32(i))
if let identity = try? NostrIdentity(privateKeyData: keyData) {
// Cache the result
cacheLock.lock()
derivedIdentityCache[geohash] = identity
cacheLock.unlock()
return identity
}
}
// As a final fallback, hash the seed+msg and try again
let fallback = (seed + msg).sha256Hash()
let identity = try NostrIdentity(privateKeyData: fallback)
// Cache the result
cacheLock.lock()
derivedIdentityCache[geohash] = identity
cacheLock.unlock()
return identity
}
}
+27 -9
View File
@@ -1,3 +1,4 @@
import BitLogger
import Foundation
import CryptoKit
import P256K
@@ -78,8 +79,7 @@ struct NostrProtocol {
)
// Successfully unwrapped gift wrap
} catch {
SecureLogger.log("❌ Failed to unwrap gift wrap: \(error)",
category: SecureLogger.session, level: .error)
SecureLogger.error("❌ Failed to unwrap gift wrap: \(error)", category: .session)
throw error
}
@@ -92,8 +92,7 @@ struct NostrProtocol {
)
// Successfully opened seal
} catch {
SecureLogger.log("❌ Failed to open seal: \(error)",
category: SecureLogger.session, level: .error)
SecureLogger.error("❌ Failed to open seal: \(error)", category: .session)
throw error
}
@@ -109,7 +108,7 @@ struct NostrProtocol {
teleported: Bool = false
) throws -> NostrEvent {
var tags = [["g", geohash]]
if let nickname = nickname, !nickname.isEmpty {
if let nickname = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty {
tags.append(["n", nickname])
}
if teleported {
@@ -125,6 +124,28 @@ struct NostrProtocol {
let schnorrKey = try senderIdentity.schnorrSigningKey()
return try event.sign(with: schnorrKey)
}
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
static func createGeohashTextNote(
content: String,
geohash: String,
senderIdentity: NostrIdentity,
nickname: String? = nil
) throws -> NostrEvent {
var tags = [["g", geohash]]
if let nickname = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty {
tags.append(["n", nickname])
}
let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .textNote,
tags: tags,
content: content
)
let schnorrKey = try senderIdentity.schnorrSigningKey()
return try event.sign(with: schnorrKey)
}
// MARK: - Private Methods
@@ -500,10 +521,7 @@ struct NostrEvent: Codable {
] as [Any]
let data = try JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
let hash = CryptoKit.SHA256.hash(data: data)
let hashData = Data(hash)
let hashHex = hash.compactMap { String(format: "%02x", $0) }.joined()
return (hashHex, hashData)
return (data.sha256Fingerprint(), data.sha256Hash())
}
func jsonString() throws -> String {
+499 -164
View File
@@ -1,10 +1,12 @@
import BitLogger
import Foundation
import Network
import Combine
import Tor
/// Manages WebSocket connections to Nostr relays
@MainActor
class NostrRelayManager: ObservableObject {
final class NostrRelayManager: ObservableObject {
static let shared = NostrRelayManager()
// Track gift-wraps (kind 1059) we initiated so we can log OK acks at info
private(set) static var pendingGiftWrapIDs = Set<String>()
@@ -34,18 +36,42 @@ class NostrRelayManager: ObservableObject {
"wss://nostr21.com"
// For local testing, you can add: "ws://localhost:8080"
]
private static let defaultRelaySet = Set(defaultRelays)
@Published private(set) var relays: [Relay] = []
@Published private(set) var isConnected = false
private var allowDefaultRelays: Bool = false
private var hasMutualFavorites: Bool = false
private var hasLocationPermission: Bool = false
private var connections: [String: URLSessionWebSocketTask] = [:]
private var subscriptions: [String: Set<String>] = [:] // relay URL -> subscription IDs
private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs
private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
private var messageHandlers: [String: (NostrEvent) -> Void] = [:]
// Coalesce duplicate subscribe requests for the same id within a short window
private var subscribeCoalesce: [String: Date] = [:]
private var cancellables = Set<AnyCancellable>()
// Track EOSE per subscription to signal when initial stored events are done
private struct EOSETracker {
var pendingRelays: Set<String>
var callback: () -> Void
var timer: Timer?
}
private var eoseTrackers: [String: EOSETracker] = [:]
// Message queue for reliability
private var messageQueue: [(event: NostrEvent, relayUrls: [String])] = []
// Pending sends held only for relays that are not yet connected.
private struct PendingSend {
var event: NostrEvent
var pendingRelays: Set<String>
}
private var messageQueue: [PendingSend] = []
private let messageQueueLock = NSLock()
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
private var networkService: NetworkActivationService { NetworkActivationService.shared }
private var shouldUseTor: Bool { networkService.userTorEnabled }
// Exponential backoff configuration
private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
@@ -55,56 +81,168 @@ class NostrRelayManager: ObservableObject {
// Reconnection timer
private var reconnectionTimer: Timer?
// Bump generation to invalidate scheduled reconnects when we reset/disconnect
private var connectionGeneration: Int = 0
init() {
// Initialize with default relays
self.relays = Self.defaultRelays.map { Relay(url: $0) }
hasMutualFavorites = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
hasLocationPermission = LocationChannelManager.shared.permissionState == .authorized
applyDefaultRelayPolicy(force: true)
// Deterministic JSON shape for outbound requests
self.encoder.outputFormatting = .sortedKeys
FavoritesPersistenceService.shared.$mutualFavorites
.receive(on: DispatchQueue.main)
.sink { [weak self] favorites in
guard let self = self else { return }
self.hasMutualFavorites = !favorites.isEmpty
self.applyDefaultRelayPolicy()
}
.store(in: &cancellables)
LocationChannelManager.shared.$permissionState
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
guard let self = self else { return }
let authorized = (state == .authorized)
if authorized == self.hasLocationPermission { return }
self.hasLocationPermission = authorized
self.applyDefaultRelayPolicy()
}
.store(in: &cancellables)
}
/// Connect to all configured relays
func connect() {
SecureLogger.log("🌐 Connecting to \(relays.count) Nostr relays", category: SecureLogger.session, level: .debug)
for relay in relays {
connectToRelay(relay.url)
// Global network policy gate
guard networkService.activationAllowed else { return }
if shouldUseTor {
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
Task.detached {
let ready = await TorManager.shared.awaitReady()
await MainActor.run {
if !ready {
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
return
}
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session)
for relay in self.relays {
self.connectToRelay(relay.url)
}
}
}
} else {
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (direct)", category: .session)
for relay in self.relays {
connectToRelay(relay.url)
}
}
}
/// Disconnect from all relays
func disconnect() {
connectionGeneration &+= 1
for (_, task) in connections {
task.cancel(with: .goingAway, reason: nil)
}
connections.removeAll()
// Clear known subscriptions and any queued subs since connections are gone
subscriptions.removeAll()
pendingSubscriptions.removeAll()
updateConnectionStatus()
}
/// Ensure connections exist to the given relay URLs (idempotent).
func ensureConnections(to relayUrls: [String]) {
let existing = Set(relays.map { $0.url })
for url in Set(relayUrls) {
if !existing.contains(url) {
relays.append(Relay(url: url))
}
if connections[url] == nil {
connectToRelay(url)
// Global network policy gate
guard networkService.activationAllowed else { return }
let targets = allowedRelayList(from: relayUrls)
guard !targets.isEmpty else { return }
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
// Defer until Tor is fully ready; avoid queuing connection attempts early
Task.detached { [weak self] in
guard let self = self else { return }
let ready = await TorManager.shared.awaitReady()
await MainActor.run { if ready { self.ensureConnections(to: relayUrls) } }
}
return
}
var existing = Set(relays.map { $0.url })
for url in targets where !existing.contains(url) {
relays.append(Relay(url: url))
existing.insert(url)
}
for url in targets where connections[url] == nil {
connectToRelay(url)
}
}
/// Send an event to specified relays (or all if none specified)
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
let targetRelays = relayUrls ?? Self.defaultRelays
// Global network policy gate
guard networkService.activationAllowed else { return }
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
// Defer sends until Tor is ready to avoid premature queueing
Task.detached { [weak self] in
guard let self = self else { return }
let ready = await TorManager.shared.awaitReady()
await MainActor.run { if ready { self.sendEvent(event, to: relayUrls) } }
}
return
}
let requestedRelays = relayUrls ?? Self.defaultRelays
let targetRelays = allowedRelayList(from: requestedRelays)
guard !targetRelays.isEmpty else { return }
ensureConnections(to: targetRelays)
// Add to queue for reliability
messageQueueLock.lock()
messageQueue.append((event, targetRelays))
messageQueueLock.unlock()
// Attempt immediate send
// Attempt immediate send to relays with active connections; queue the rest
var stillPending = Set<String>()
for relayUrl in targetRelays {
if let connection = connections[relayUrl] {
sendToRelay(event: event, connection: connection, relayUrl: relayUrl)
} else {
stillPending.insert(relayUrl)
}
}
if !stillPending.isEmpty {
messageQueueLock.lock()
messageQueue.append(PendingSend(event: event, pendingRelays: stillPending))
messageQueueLock.unlock()
}
}
/// Try to flush any queued messages for relays that are now connected.
private func flushMessageQueue(for relayUrl: String? = nil) {
messageQueueLock.lock()
defer { messageQueueLock.unlock() }
guard !messageQueue.isEmpty else { return }
if let target = relayUrl {
// Flush only for a specific relay
for i in (0..<messageQueue.count).reversed() {
var item = messageQueue[i]
if item.pendingRelays.contains(target), let conn = connections[target] {
sendToRelay(event: item.event, connection: conn, relayUrl: target)
item.pendingRelays.remove(target)
if item.pendingRelays.isEmpty {
messageQueue.remove(at: i)
} else {
messageQueue[i] = item
}
}
}
} else {
// Flush for any relays that now have connections
for i in (0..<messageQueue.count).reversed() {
var item = messageQueue[i]
for url in item.pendingRelays {
if let conn = connections[url] {
sendToRelay(event: item.event, connection: conn, relayUrl: url)
item.pendingRelays.remove(url)
}
}
if item.pendingRelays.isEmpty {
messageQueue.remove(at: i)
} else {
messageQueue[i] = item
}
}
}
}
@@ -114,68 +252,150 @@ class NostrRelayManager: ObservableObject {
filter: NostrFilter,
id: String = UUID().uuidString,
relayUrls: [String]? = nil,
handler: @escaping (NostrEvent) -> Void
handler: @escaping (NostrEvent) -> Void,
onEOSE: (() -> Void)? = nil
) {
// Global network policy gate
guard networkService.activationAllowed else { return }
// Coalesce rapid duplicate subscribe requests only if a handler already exists
let now = Date()
if messageHandlers[id] != nil {
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < 1.0 {
return
}
}
subscribeCoalesce[id] = now
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
// Defer subscription setup until Tor is ready; avoid queuing subs early
Task.detached { [weak self] in
guard let self = self else { return }
let ready = await TorManager.shared.awaitReady()
await MainActor.run {
if ready {
self.subscribe(filter: filter, id: id, relayUrls: relayUrls, handler: handler)
}
}
}
return
}
messageHandlers[id] = handler
SecureLogger.log("📡 Subscribing to Nostr filter id=\(id) kinds=\(filter.kinds ?? []) since=\(filter.since ?? 0)",
category: SecureLogger.session, level: .debug)
let req = NostrRequest.subscribe(id: id, filters: [filter])
do {
let encoder = JSONEncoder()
encoder.outputFormatting = .sortedKeys // For consistent output
let message = try encoder.encode(req)
guard let messageString = String(data: message, encoding: .utf8) else {
SecureLogger.log("❌ Failed to encode subscription request", category: SecureLogger.session, level: .error)
SecureLogger.error("❌ Failed to encode subscription request", category: .session)
return
}
// SecureLogger.log("📋 Subscription filter JSON: \(messageString.prefix(200))...",
// category: SecureLogger.session, level: .debug)
// SecureLogger.debug("📋 Subscription filter JSON: \(messageString.prefix(200))...", category: .session)
// Target specific relays if provided; else all connections
let urls = relayUrls ?? Self.defaultRelays
ensureConnections(to: urls)
let targets: [(String, URLSessionWebSocketTask)] = urls.compactMap { url in
connections[url].map { (url, $0) }
// Target specific relays if provided; else default. Filter permanently failed relays.
let baseUrls = relayUrls ?? Self.defaultRelays
let candidateUrls = baseUrls.filter { !isPermanentlyFailed($0) }
let urls = allowedRelayList(from: candidateUrls)
// Always queue subscriptions; sending happens when a relay reports connected
let existingSet = Set(relays.map { $0.url })
for url in urls where !existingSet.contains(url) {
relays.append(Relay(url: url))
}
for (relayUrl, connection) in targets {
connection.send(.string(messageString)) { error in
if let error = error {
SecureLogger.log("❌ Failed to send subscription to \(relayUrl): \(error)",
category: SecureLogger.session, level: .error)
} else {
// SecureLogger.log(" Subscription '\(id)' sent to relay: \(relayUrl)",
// category: SecureLogger.session, level: .debug)
// Subscription sent successfully
for url in candidateUrls {
var map = self.pendingSubscriptions[url] ?? [:]
map[id] = messageString
self.pendingSubscriptions[url] = map
}
// Initialize EOSE tracking if requested
if let onEOSE = onEOSE {
if urls.isEmpty {
onEOSE()
} else {
var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil)
// Fallback timeout to avoid hanging if a relay never sends EOSE
tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
Task { @MainActor in
var subs = self.subscriptions[relayUrl] ?? Set<String>()
subs.insert(id)
self.subscriptions[relayUrl] = subs
guard let self = self else { return }
if let t = self.eoseTrackers[id] {
t.timer?.invalidate()
self.eoseTrackers.removeValue(forKey: id)
onEOSE()
}
}
}
eoseTrackers[id] = tracker
}
}
if connections.isEmpty {
SecureLogger.log("⚠️ No relay connections available for subscription",
category: SecureLogger.session, level: .warning)
SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session)
// Ensure we actually have sockets opening to these relays so queued REQs can flush
ensureConnections(to: urls)
// If some targets are already connected, flush immediately for them
for url in urls {
if let r = relays.first(where: { $0.url == url }), r.isConnected {
flushPendingSubscriptions(for: url)
}
}
} catch {
SecureLogger.log("❌ Failed to encode subscription request: \(error)",
category: SecureLogger.session, level: .error)
SecureLogger.error("❌ Failed to encode subscription request: \(error)", category: .session)
}
}
private func applyDefaultRelayPolicy(force: Bool = false) {
let shouldAllow = hasMutualFavorites || hasLocationPermission
if !force && shouldAllow == allowDefaultRelays { return }
allowDefaultRelays = shouldAllow
if shouldAllow {
var existing = Set(relays.map { $0.url })
for url in Self.defaultRelays where !existing.contains(url) {
relays.append(Relay(url: url))
existing.insert(url)
}
if networkService.activationAllowed {
ensureConnections(to: Self.defaultRelays)
}
} else {
for url in Self.defaultRelays {
if let connection = connections[url] {
connection.cancel(with: .goingAway, reason: nil)
}
connections.removeValue(forKey: url)
subscriptions.removeValue(forKey: url)
}
messageQueueLock.lock()
for index in (0..<messageQueue.count).reversed() {
var item = messageQueue[index]
item.pendingRelays.subtract(Self.defaultRelaySet)
if item.pendingRelays.isEmpty {
messageQueue.remove(at: index)
} else {
messageQueue[index] = item
}
}
messageQueueLock.unlock()
relays.removeAll { Self.defaultRelaySet.contains($0.url) }
updateConnectionStatus()
}
}
private func allowedRelayList(from urls: [String]) -> [String] {
var seen = Set<String>()
var result: [String] = []
for url in urls {
if !allowDefaultRelays && Self.defaultRelaySet.contains(url) { continue }
if seen.insert(url).inserted {
result.append(url)
}
}
return result
}
/// Unsubscribe from a subscription
func unsubscribe(id: String) {
messageHandlers.removeValue(forKey: id)
// Allow immediate re-subscription by clearing coalescer timestamp
subscribeCoalesce.removeValue(forKey: id)
let req = NostrRequest.close(id: id)
let message = try? JSONEncoder().encode(req)
let message = try? encoder.encode(req)
guard let messageData = message,
let messageString = String(data: messageData, encoding: .utf8) else { return }
@@ -195,19 +415,42 @@ class NostrRelayManager: ObservableObject {
// MARK: - Private Methods
private func connectToRelay(_ urlString: String) {
// Global network policy gate
guard networkService.activationAllowed else { return }
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
}
// Avoid initiating connections while app is backgrounded; we'll reconnect on foreground
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isForeground() {
return
}
// Skip if we already have a connection object
if connections[urlString] != nil {
return
}
if isPermanentlyFailed(urlString) {
return
}
// Attempting to connect to Nostr relay
// Attempting to connect to Nostr relay via the proxied session
let session = URLSession(configuration: .default)
// If Tor is enforced but not ready, delay connection until it is.
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
Task.detached { [weak self] in
guard let self = self else { return }
let ready = await TorManager.shared.awaitReady()
await MainActor.run {
if ready { self.connectToRelay(urlString) }
else { SecureLogger.error("❌ Tor not ready; skipping connection to \(urlString)", category: .session) }
}
}
return
}
let session = TorURLSession.shared.session
let task = session.webSocketTask(with: url)
connections[urlString] = task
@@ -220,12 +463,12 @@ class NostrRelayManager: ObservableObject {
task.sendPing { [weak self] error in
DispatchQueue.main.async {
if error == nil {
SecureLogger.log("✅ Connected to Nostr relay: \(urlString)",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("✅ Connected to Nostr relay: \(urlString)", category: .session)
self?.updateRelayStatus(urlString, isConnected: true)
// Flush any pending subscriptions for this relay
self?.flushPendingSubscriptions(for: urlString)
} else {
SecureLogger.log("❌ Failed to connect to Nostr relay \(urlString): \(error?.localizedDescription ?? "Unknown error")",
category: SecureLogger.session, level: .error)
SecureLogger.error("❌ Failed to connect to Nostr relay \(urlString): \(error?.localizedDescription ?? "Unknown error")", category: .session)
self?.updateRelayStatus(urlString, isConnected: false, error: error)
// Trigger disconnection handler for proper backoff
self?.handleDisconnection(relayUrl: urlString, error: error ?? NSError(domain: "NostrRelay", code: -1, userInfo: nil))
@@ -233,6 +476,27 @@ class NostrRelayManager: ObservableObject {
}
}
}
/// Send any queued subscriptions for a relay that just connected.
private func flushPendingSubscriptions(for relayUrl: String) {
guard let map = pendingSubscriptions[relayUrl], !map.isEmpty else { return }
guard let connection = connections[relayUrl] else { return }
for (id, messageString) in map {
if self.subscriptions[relayUrl]?.contains(id) == true { continue }
connection.send(.string(messageString)) { error in
if let error = error {
SecureLogger.error("❌ Failed to send pending subscription to \(relayUrl): \(error)", category: .session)
} else {
Task { @MainActor in
var subs = self.subscriptions[relayUrl] ?? Set<String>()
subs.insert(id)
self.subscriptions[relayUrl] = subs
}
}
}
}
pendingSubscriptions[relayUrl] = nil
}
private func receiveMessage(from task: URLSessionWebSocketTask, relayUrl: String) {
task.receive { [weak self] result in
@@ -240,19 +504,12 @@ class NostrRelayManager: ObservableObject {
switch result {
case .success(let message):
switch message {
case .string(let text):
Task { @MainActor in
self.handleMessage(text, from: relayUrl)
// Parse off-main to reduce UI jank, then hop back for state updates
Task.detached(priority: .utility) {
guard let parsed = ParsedInbound(message) else { return }
await MainActor.run {
NostrRelayManager.shared.handleParsedMessage(parsed, from: relayUrl)
}
case .data(let data):
if let text = String(data: data, encoding: .utf8) {
Task { @MainActor in
self.handleMessage(text, from: relayUrl)
}
}
@unknown default:
break
}
// Continue receiving
@@ -268,79 +525,50 @@ class NostrRelayManager: ObservableObject {
}
}
private func handleMessage(_ message: String, from relayUrl: String) {
guard let data = message.data(using: .utf8) else { return }
do {
// Try to decode as an array first
if let array = try JSONSerialization.jsonObject(with: data) as? [Any],
array.count >= 2,
let type = array[0] as? String {
// Received message from relay
switch type {
case "EVENT":
if array.count >= 3,
let subId = array[1] as? String,
let eventDict = array[2] as? [String: Any] {
let event = try NostrEvent(from: eventDict)
// Only log non-gift-wrap events to reduce noise
if event.kind != 1059 {
SecureLogger.log("📥 Event kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)",
category: SecureLogger.session, level: .debug)
}
DispatchQueue.main.async {
// Update relay stats
if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) {
self.relays[index].messagesReceived += 1
}
// Call handler
if let handler = self.messageHandlers[subId] {
handler(event)
} else {
SecureLogger.log("⚠️ No handler for subscription \(subId)",
category: SecureLogger.session, level: .warning)
}
}
}
case "EOSE":
if array.count >= 2 {
// End of stored events
}
case "OK":
if array.count >= 3,
let eventId = array[1] as? String,
let success = array[2] as? Bool {
let reason = array.count >= 4 ? (array[3] as? String ?? "no reason given") : "no reason given"
if success {
_ = Self.pendingGiftWrapIDs.remove(eventId)
SecureLogger.log("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)",
category: SecureLogger.session, level: .debug)
} else {
let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil
SecureLogger.log("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)",
category: SecureLogger.session, level: isGiftWrap ? .warning : .error)
}
}
case "NOTICE":
if array.count >= 2 {
// Server notice received
}
default:
break // Unknown message type
// Parsed inbound message type (off-main)
// Note: declared at file scope below to avoid MainActor isolation inside this class
// and keep parsing off the main actor.
// Handle parsed message on MainActor (state updates and handlers)
private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) {
switch parsed {
case .event(let subId, let event):
if event.kind != 1059 {
SecureLogger.debug("📥 Event kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
}
if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) {
self.relays[index].messagesReceived += 1
}
if let handler = self.messageHandlers[subId] {
handler(event)
} else {
SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session)
}
case .eose(let subId):
if var tracker = eoseTrackers[subId] {
tracker.pendingRelays.remove(relayUrl)
if tracker.pendingRelays.isEmpty {
tracker.timer?.invalidate()
eoseTrackers.removeValue(forKey: subId)
tracker.callback()
} else {
eoseTrackers[subId] = tracker
}
}
} catch {
SecureLogger.log("Failed to parse Nostr message: \(error)", category: SecureLogger.session, level: .error)
case .ok(let eventId, let success, let reason):
if success {
_ = Self.pendingGiftWrapIDs.remove(eventId)
SecureLogger.debug("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)", category: .session)
} else {
let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil
if isGiftWrap {
SecureLogger.warning("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)", category: .session)
} else {
SecureLogger.error("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)", category: .session)
}
}
case .notice:
break
}
}
@@ -348,22 +576,17 @@ class NostrRelayManager: ObservableObject {
let req = NostrRequest.event(event)
do {
let encoder = JSONEncoder()
encoder.outputFormatting = .sortedKeys
let data = try encoder.encode(req)
let message = String(data: data, encoding: .utf8) ?? ""
SecureLogger.log("📤 Send kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("📤 Send kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
connection.send(.string(message)) { [weak self] error in
DispatchQueue.main.async {
if let error = error {
SecureLogger.log("❌ Failed to send event to \(relayUrl): \(error)",
category: SecureLogger.session, level: .error)
SecureLogger.error("❌ Failed to send event to \(relayUrl): \(error)", category: .session)
} else {
// SecureLogger.log(" Event sent to relay: \(relayUrl)",
// category: SecureLogger.session, level: .debug)
// SecureLogger.debug(" Event sent to relay: \(relayUrl)", category: .session)
// Update relay stats
if let index = self?.relays.firstIndex(where: { $0.url == relayUrl }) {
self?.relays[index].messagesSent += 1
@@ -372,7 +595,7 @@ class NostrRelayManager: ObservableObject {
}
}
} catch {
SecureLogger.log("Failed to encode event: \(error)", category: SecureLogger.session, level: .error)
SecureLogger.error("Failed to encode event: \(error)", category: .session)
}
}
@@ -389,6 +612,10 @@ class NostrRelayManager: ObservableObject {
}
}
updateConnectionStatus()
// If we just connected to this relay, flush any queued sends targeting it
if isConnected {
flushMessageQueue(for: url)
}
}
private func updateConnectionStatus() {
@@ -396,22 +623,32 @@ class NostrRelayManager: ObservableObject {
}
private func handleDisconnection(relayUrl: String, error: Error) {
// If networking is disallowed, do not schedule reconnection
if !networkService.activationAllowed {
connections.removeValue(forKey: relayUrl)
subscriptions.removeValue(forKey: relayUrl)
updateRelayStatus(relayUrl, isConnected: false, error: error)
return
}
connections.removeValue(forKey: relayUrl)
subscriptions.removeValue(forKey: relayUrl)
updateRelayStatus(relayUrl, isConnected: false, error: error)
// Check if this is a DNS error
// Check if this is a DNS or handshake error; treat as permanent
let errorDescription = error.localizedDescription.lowercased()
let ns = error as NSError
if errorDescription.contains("hostname could not be found") ||
errorDescription.contains("dns") {
// Only log once for DNS failures
errorDescription.contains("dns") ||
(ns.domain == NSURLErrorDomain && ns.code == NSURLErrorBadServerResponse) {
if relays.first(where: { $0.url == relayUrl })?.lastError == nil {
SecureLogger.log("Nostr relay DNS failure for \(relayUrl) - not retrying", category: SecureLogger.session, level: .warning)
SecureLogger.warning("Nostr relay permanent failure for \(relayUrl) - not retrying (code=\(ns.code))", category: .session)
}
// Mark relay as permanently failed
if let index = relays.firstIndex(where: { $0.url == relayUrl }) {
relays[index].lastError = error
relays[index].reconnectAttempts = maxReconnectAttempts
relays[index].nextReconnectTime = nil
}
pendingSubscriptions[relayUrl] = nil
return
}
@@ -422,8 +659,7 @@ class NostrRelayManager: ObservableObject {
// Stop attempting after max attempts
if relays[index].reconnectAttempts >= maxReconnectAttempts {
SecureLogger.log("Max reconnection attempts (\(maxReconnectAttempts)) reached for \(relayUrl)",
category: SecureLogger.session, level: .warning)
SecureLogger.warning("Max reconnection attempts (\(maxReconnectAttempts)) reached for \(relayUrl)", category: .session)
return
}
@@ -438,9 +674,11 @@ class NostrRelayManager: ObservableObject {
// Schedule reconnection with exponential backoff
let gen = connectionGeneration
DispatchQueue.main.asyncAfter(deadline: .now() + backoffInterval) { [weak self] in
guard let self = self else { return }
// Ignore stale scheduled reconnects from a previous generation
guard gen == self.connectionGeneration else { return }
// Check if we should still reconnect (relay might have been removed)
if self.relays.contains(where: { $0.url == relayUrl }) {
self.connectToRelay(relayUrl)
@@ -481,6 +719,8 @@ class NostrRelayManager: ObservableObject {
/// Reset all relay connections
func resetAllConnections() {
disconnect()
// New generation begins now
connectionGeneration &+= 1
// Reset all relay states
for index in relays.indices {
@@ -492,6 +732,81 @@ class NostrRelayManager: ObservableObject {
// Reconnect
connect()
}
// MARK: - Failure classification
private func isPermanentlyFailed(_ url: String) -> Bool {
guard let r = relays.first(where: { $0.url == url }) else { return false }
if r.reconnectAttempts >= maxReconnectAttempts { return true }
if let ns = r.lastError as NSError?, ns.domain == NSURLErrorDomain {
if ns.code == NSURLErrorBadServerResponse || ns.code == NSURLErrorCannotFindHost {
return true
}
}
return false
}
}
// MARK: - Off-main inbound parsing helpers (file scope, non-isolated)
private enum ParsedInbound {
case event(subId: String, event: NostrEvent)
case ok(eventId: String, success: Bool, reason: String)
case eose(subscriptionId: String)
case notice(String)
init?(_ message: URLSessionWebSocketTask.Message) {
guard let data = message.data,
let array = try? JSONSerialization.jsonObject(with: data) as? [Any],
array.count >= 2,
let type = array[0] as? String else {
return nil
}
switch type {
case "EVENT":
if array.count >= 3,
let subId = array[1] as? String,
let eventDict = array[2] as? [String: Any],
let event = try? NostrEvent(from: eventDict) {
self = .event(subId: subId, event: event)
return
}
return nil
case "EOSE":
if let subId = array[1] as? String {
self = .eose(subscriptionId: subId)
return
}
return nil
case "OK":
if array.count >= 3,
let eventId = array[1] as? String,
let success = array[2] as? Bool {
let reason = array.count >= 4 ? (array[3] as? String ?? "no reason given") : "no reason given"
self = .ok(eventId: eventId, success: success, reason: reason)
return
}
return nil
case "NOTICE":
if array.count >= 2, let msg = array[1] as? String {
self = .notice(msg)
return
}
return nil
default:
return nil
}
}
}
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
}
}
}
// MARK: - Nostr Protocol Types
@@ -581,6 +896,26 @@ struct NostrFilter: Encodable {
filter.limit = limit
return filter
}
// For location notes: persistent text notes (kind 1) tagged with geohash
static func geohashNotes(_ geohash: String, since: Date? = nil, limit: Int = 200) -> NostrFilter {
var filter = NostrFilter()
filter.kinds = [1]
filter.since = since?.timeIntervalSince1970.toInt()
filter.tagFilters = ["g": [geohash]]
filter.limit = limit
return filter
}
// For location notes with neighbors: subscribe to multiple geohashes (center + neighbors)
static func geohashNotes(_ geohashes: [String], since: Date? = nil, limit: Int = 200) -> NostrFilter {
var filter = NostrFilter()
filter.kinds = [1]
filter.since = since?.timeIntervalSince1970.toInt()
filter.tagFilters = ["g": geohashes]
filter.limit = limit
return filter
}
}
// Dynamic coding key for tag filters
+28 -9
View File
@@ -5,16 +5,27 @@ import CryptoKit
/// Implements HChaCha20 to derive a subkey and reduces the 24-byte nonce to a 12-byte nonce
/// as per XChaCha20 construction.
enum XChaCha20Poly1305Compat {
/// Errors that can occur during XChaCha20-Poly1305 operations
enum Error: Swift.Error {
case invalidKeyLength(expected: Int, got: Int)
case invalidNonceLength(expected: Int, got: Int)
}
struct SealBox {
let ciphertext: Data
let tag: Data
}
static func seal(plaintext: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> SealBox {
precondition(key.count == 32, "XChaCha20 key must be 32 bytes")
precondition(nonce24.count == 24, "XChaCha20 nonce must be 24 bytes")
guard key.count == 32 else {
throw Error.invalidKeyLength(expected: 32, got: key.count)
}
guard nonce24.count == 24 else {
throw Error.invalidNonceLength(expected: 24, got: nonce24.count)
}
let subkey = hchacha20(key: key, nonce16: nonce24.prefix(16))
let subkey = try hchacha20(key: key, nonce16: Data(nonce24.prefix(16)))
let nonce12 = derive12ByteNonce(from24: nonce24)
let chachaKey = SymmetricKey(data: subkey)
let nonce = try ChaChaPoly.Nonce(data: nonce12)
@@ -23,10 +34,14 @@ enum XChaCha20Poly1305Compat {
}
static func open(ciphertext: Data, tag: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> Data {
precondition(key.count == 32, "XChaCha20 key must be 32 bytes")
precondition(nonce24.count == 24, "XChaCha20 nonce must be 24 bytes")
guard key.count == 32 else {
throw Error.invalidKeyLength(expected: 32, got: key.count)
}
guard nonce24.count == 24 else {
throw Error.invalidNonceLength(expected: 24, got: nonce24.count)
}
let subkey = hchacha20(key: key, nonce16: nonce24.prefix(16))
let subkey = try hchacha20(key: key, nonce16: Data(nonce24.prefix(16)))
let nonce12 = derive12ByteNonce(from24: nonce24)
let chachaKey = SymmetricKey(data: subkey)
let box = try ChaChaPoly.SealedBox(nonce: ChaChaPoly.Nonce(data: nonce12), ciphertext: ciphertext, tag: tag)
@@ -43,10 +58,14 @@ enum XChaCha20Poly1305Compat {
return out
}
private static func hchacha20(key: Data, nonce16: Data) -> Data {
private static func hchacha20(key: Data, nonce16: Data) throws -> Data {
// HChaCha20 based on the original ChaCha20 core with a 16-byte nonce.
precondition(key.count == 32)
precondition(nonce16.count == 16)
guard key.count == 32 else {
throw Error.invalidKeyLength(expected: 32, got: key.count)
}
guard nonce16.count == 16 else {
throw Error.invalidNonceLength(expected: 16, got: nonce16.count)
}
// Constants "expand 32-byte k"
var state: [UInt32] = [
+15 -10
View File
@@ -6,6 +6,7 @@
//
import Foundation
import CryptoKit
// MARK: - Hex Encoding/Decoding
@@ -16,6 +17,11 @@ extension Data {
}
return self.map { String(format: "%02x", $0) }.joined()
}
func sha256Hex() -> String {
let digest = SHA256.hash(data: self)
return digest.map { String(format: "%02x", $0) }.joined()
}
init?(hexString: String) {
let len = hexString.count / 2
@@ -40,23 +46,23 @@ extension Data {
extension Data {
// MARK: Writing
mutating func appendUInt8(_ value: UInt8) {
@inlinable mutating func appendUInt8(_ value: UInt8) {
self.append(value)
}
mutating func appendUInt16(_ value: UInt16) {
@inlinable mutating func appendUInt16(_ value: UInt16) {
self.append(UInt8((value >> 8) & 0xFF))
self.append(UInt8(value & 0xFF))
}
mutating func appendUInt32(_ value: UInt32) {
@inlinable mutating func appendUInt32(_ value: UInt32) {
self.append(UInt8((value >> 24) & 0xFF))
self.append(UInt8((value >> 16) & 0xFF))
self.append(UInt8((value >> 8) & 0xFF))
self.append(UInt8(value & 0xFF))
}
mutating func appendUInt64(_ value: UInt64) {
@inlinable mutating func appendUInt64(_ value: UInt64) {
for i in (0..<8).reversed() {
self.append(UInt8((value >> (i * 8)) & 0xFF))
}
@@ -113,21 +119,21 @@ extension Data {
// MARK: Reading
func readUInt8(at offset: inout Int) -> UInt8? {
@inlinable func readUInt8(at offset: inout Int) -> UInt8? {
guard offset >= 0 && offset < self.count else { return nil }
let value = self[offset]
offset += 1
return value
}
func readUInt16(at offset: inout Int) -> UInt16? {
@inlinable func readUInt16(at offset: inout Int) -> UInt16? {
guard offset + 2 <= self.count else { return nil }
let value = UInt16(self[offset]) << 8 | UInt16(self[offset + 1])
offset += 2
return value
}
func readUInt32(at offset: inout Int) -> UInt32? {
@inlinable func readUInt32(at offset: inout Int) -> UInt32? {
guard offset + 4 <= self.count else { return nil }
let value = UInt32(self[offset]) << 24 |
UInt32(self[offset + 1]) << 16 |
@@ -137,7 +143,7 @@ extension Data {
return value
}
func readUInt64(at offset: inout Int) -> UInt64? {
@inlinable func readUInt64(at offset: inout Int) -> UInt64? {
guard offset + 8 <= self.count else { return nil }
var value: UInt64 = 0
for i in 0..<8 {
@@ -197,7 +203,7 @@ extension Data {
offset += 16
// Convert 16 bytes to UUID string format
let uuid = uuidData.map { String(format: "%02x", $0) }.joined()
let uuid = uuidData.hexEncodedString()
// Insert hyphens at proper positions: 8-4-4-4-12
var result = ""
@@ -220,4 +226,3 @@ extension Data {
return data
}
}
+247 -417
View File
@@ -22,11 +22,11 @@
///
/// ## Wire Format
/// ```
/// Header (Fixed 13 bytes):
/// +--------+------+-----+-----------+-------+----------------+
/// |Version | Type | TTL | Timestamp | Flags | PayloadLength |
/// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 bytes |
/// +--------+------+-----+-----------+-------+----------------+
/// Header (Fixed 14 bytes for v1, 16 bytes for v2):
/// +--------+------+-----+-----------+-------+------------------+
/// |Version | Type | TTL | Timestamp | Flags | PayloadLength |
/// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 or 4 bytes |
/// +--------+------+-----+-----------+-------+------------------+
///
/// Variable sections:
/// +----------+-------------+---------+------------+
@@ -52,7 +52,7 @@
/// ## Flag Bits
/// - Bit 0: Has recipient ID (directed message)
/// - Bit 1: Has signature (authenticated message)
/// - Bit 2: Is compressed (LZ4 compression applied)
/// - Bit 2: Is compressed (zlib compression applied)
/// - Bits 3-7: Reserved for future use
///
/// ## Size Constraints
@@ -89,6 +89,7 @@
///
import Foundation
import BitLogger
extension Data {
func trimmingNullBytes() -> Data {
@@ -105,78 +106,119 @@ extension Data {
/// their binary wire format representation.
/// - Note: All multi-byte values use network byte order (big-endian)
struct BinaryProtocol {
static let headerSize = 13
static let v1HeaderSize = 14
static let v2HeaderSize = 16
static let senderIDSize = 8
static let recipientIDSize = 8
static let signatureSize = 64
// Field offsets within packet header
struct Offsets {
static let version = 0
static let type = 1
static let ttl = 2
static let timestamp = 3
static let flags = 11 // After version(1) + type(1) + ttl(1) + timestamp(8)
}
static func headerSize(for version: UInt8) -> Int? {
switch version {
case 1: return v1HeaderSize
case 2: return v2HeaderSize
default: return nil
}
}
private static func lengthFieldSize(for version: UInt8) -> Int {
return version == 2 ? 4 : 2
}
struct Flags {
static let hasRecipient: UInt8 = 0x01
static let hasSignature: UInt8 = 0x02
static let isCompressed: UInt8 = 0x04
static let hasRoute: UInt8 = 0x08
}
// Encode BitchatPacket to binary format
static func encode(_ packet: BitchatPacket, padding: Bool = true) -> Data? {
var data = Data()
// Try to compress payload if beneficial
let version = packet.version
guard version == 1 || version == 2 else { return nil }
// Try to compress payload when beneficial, keeping original size for later decoding
var payload = packet.payload
var originalPayloadSize: UInt16? = nil
var isCompressed = false
var originalPayloadSize: Int?
if CompressionUtil.shouldCompress(payload) {
if let compressedPayload = CompressionUtil.compress(payload) {
// Store original size for decompression (2 bytes after payload)
originalPayloadSize = UInt16(payload.count)
// Only compress when we can represent the original length in the outbound frame
let maxRepresentable = version == 2 ? Int(UInt32.max) : Int(UInt16.max)
if payload.count <= maxRepresentable,
let compressedPayload = CompressionUtil.compress(payload) {
originalPayloadSize = payload.count
payload = compressedPayload
isCompressed = true
} else {
}
} else {
}
// Header
data.append(packet.version)
let lengthFieldBytes = lengthFieldSize(for: version)
let originalRoute = packet.route ?? []
if originalRoute.contains(where: { $0.isEmpty }) { return nil }
let sanitizedRoute: [Data] = originalRoute.map { hop in
if hop.count == senderIDSize { return hop }
if hop.count > senderIDSize { return Data(hop.prefix(senderIDSize)) }
var padded = hop
padded.append(Data(repeating: 0, count: senderIDSize - hop.count))
return padded
}
guard sanitizedRoute.count <= 255 else { return nil }
let hasRoute = !sanitizedRoute.isEmpty
let routeLength = hasRoute ? 1 + sanitizedRoute.count * senderIDSize : 0
let originalSizeFieldBytes = isCompressed ? lengthFieldBytes : 0
let payloadDataSize = routeLength + payload.count + originalSizeFieldBytes
if version == 1 && payloadDataSize > Int(UInt16.max) { return nil }
if version == 2 && payloadDataSize > Int(UInt32.max) { return nil }
guard let headerSize = headerSize(for: version) else { return nil }
let estimatedHeader = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize)
let estimatedPayload = payloadDataSize
let estimatedSignature = (packet.signature == nil ? 0 : signatureSize)
var data = Data()
data.reserveCapacity(estimatedHeader + estimatedPayload + estimatedSignature + 255)
data.append(version)
data.append(packet.type)
data.append(packet.ttl)
// Timestamp (8 bytes, big-endian)
for i in (0..<8).reversed() {
data.append(UInt8((packet.timestamp >> (i * 8)) & 0xFF))
for shift in stride(from: 56, through: 0, by: -8) {
data.append(UInt8((packet.timestamp >> UInt64(shift)) & 0xFF))
}
// Flags
var flags: UInt8 = 0
if packet.recipientID != nil {
flags |= Flags.hasRecipient
}
if packet.signature != nil {
flags |= Flags.hasSignature
}
if isCompressed {
flags |= Flags.isCompressed
}
if packet.recipientID != nil { flags |= Flags.hasRecipient }
if packet.signature != nil { flags |= Flags.hasSignature }
if isCompressed { flags |= Flags.isCompressed }
if hasRoute { flags |= Flags.hasRoute }
data.append(flags)
// Payload length (2 bytes, big-endian) - includes original size if compressed
let payloadDataSize = payload.count + (isCompressed ? 2 : 0)
let payloadLength = UInt16(payloadDataSize)
data.append(UInt8((payloadLength >> 8) & 0xFF))
data.append(UInt8(payloadLength & 0xFF))
// SenderID (exactly 8 bytes)
if version == 2 {
let length = UInt32(payloadDataSize)
for shift in stride(from: 24, through: 0, by: -8) {
data.append(UInt8((length >> UInt32(shift)) & 0xFF))
}
} else {
let length = UInt16(payloadDataSize)
data.append(UInt8((length >> 8) & 0xFF))
data.append(UInt8(length & 0xFF))
}
let senderBytes = packet.senderID.prefix(senderIDSize)
data.append(senderBytes)
if senderBytes.count < senderIDSize {
data.append(Data(repeating: 0, count: senderIDSize - senderBytes.count))
}
// RecipientID (if present)
if let recipientID = packet.recipientID {
let recipientBytes = recipientID.prefix(recipientIDSize)
data.append(recipientBytes)
@@ -184,30 +226,37 @@ struct BinaryProtocol {
data.append(Data(repeating: 0, count: recipientIDSize - recipientBytes.count))
}
}
// Payload (with original size prepended if compressed)
if hasRoute {
data.append(UInt8(sanitizedRoute.count))
for hop in sanitizedRoute {
data.append(hop)
}
}
if isCompressed, let originalSize = originalPayloadSize {
// Prepend original size (2 bytes, big-endian)
data.append(UInt8((originalSize >> 8) & 0xFF))
data.append(UInt8(originalSize & 0xFF))
if version == 2 {
let value = UInt32(originalSize)
for shift in stride(from: 24, through: 0, by: -8) {
data.append(UInt8((value >> UInt32(shift)) & 0xFF))
}
} else {
let value = UInt16(originalSize)
data.append(UInt8((value >> 8) & 0xFF))
data.append(UInt8(value & 0xFF))
}
}
data.append(payload)
// Signature (if present)
if let signature = packet.signature {
data.append(signature.prefix(signatureSize))
}
// Apply padding to standard block sizes for traffic analysis resistance
if padding {
let optimalSize = MessagePadding.optimalBlockSize(for: data.count)
let paddedData = MessagePadding.pad(data, toSize: optimalSize)
return paddedData
} else {
// Caller explicitly requested no padding (e.g., BLE write path)
return data
return MessagePadding.pad(data, toSize: optimalSize)
}
return data
}
// Decode binary data to BitchatPacket
@@ -222,367 +271,148 @@ struct BinaryProtocol {
// Core decoding implementation used by decode(_:) with and without padding removal
private static func decodeCore(_ raw: Data) -> BitchatPacket? {
// Minimum size check: header + senderID
guard raw.count >= headerSize + senderIDSize else {
return nil
}
// Convert to array for safer indexed access
let dataArray = Array(raw)
var offset = 0
// Header parsing with bounds checks
guard offset < dataArray.count else { return nil }
let version = dataArray[offset]; offset += 1
// Check if version is 1 (only supported version)
guard version == 1 else {
return nil
}
guard offset < dataArray.count else { return nil }
let type = dataArray[offset]; offset += 1
guard offset < dataArray.count else { return nil }
let ttl = dataArray[offset]; offset += 1
// Timestamp - need 8 bytes
guard offset + 8 <= dataArray.count else { return nil }
let timestampData = Data(dataArray[offset..<offset+8])
let timestamp = timestampData.reduce(0) { result, byte in
(result << 8) | UInt64(byte)
}
offset += 8
// Flags
guard offset < dataArray.count else { return nil }
let flags = dataArray[offset]; offset += 1
let hasRecipient = (flags & Flags.hasRecipient) != 0
let hasSignature = (flags & Flags.hasSignature) != 0
let isCompressed = (flags & Flags.isCompressed) != 0
// Payload length - need 2 bytes
guard offset + 2 <= dataArray.count else { return nil }
let payloadLengthData = Data(dataArray[offset..<offset+2])
let payloadLength = payloadLengthData.reduce(0) { result, byte in
(result << 8) | UInt16(byte)
}
offset += 2
// Validate payloadLength is reasonable (prevent integer overflow)
guard payloadLength <= 65535 else { return nil }
// SenderID - need 8 bytes
guard offset + senderIDSize <= dataArray.count else { return nil }
let senderID = Data(dataArray[offset..<offset+senderIDSize])
offset += senderIDSize
// RecipientID if present
var recipientID: Data?
if hasRecipient {
guard offset + recipientIDSize <= dataArray.count else { return nil }
recipientID = Data(dataArray[offset..<offset+recipientIDSize])
offset += recipientIDSize
}
// Payload handling with comprehensive bounds checking
let payload: Data
if isCompressed {
// Compressed payload needs at least 2 bytes for original size
guard Int(payloadLength) >= 2 else { return nil }
// Check we have enough data for the original size prefix
guard offset + 2 <= dataArray.count else { return nil }
let originalSizeData = Data(dataArray[offset..<offset+2])
let originalSize = Int(originalSizeData.reduce(0) { result, byte in
(result << 8) | UInt16(byte)
})
offset += 2
// Validate original size is reasonable
guard originalSize >= 0 && originalSize <= 1048576 else { return nil } // Max 1MB
// Check we have enough data for the compressed payload
let compressedPayloadSize = Int(payloadLength) - 2
guard compressedPayloadSize >= 0 && offset + compressedPayloadSize <= dataArray.count else {
return nil
}
let compressedPayload = Data(dataArray[offset..<offset+compressedPayloadSize])
offset += compressedPayloadSize
// Decompress with error handling
guard let decompressedPayload = CompressionUtil.decompress(compressedPayload, originalSize: originalSize) else {
return nil
}
// Verify decompressed size matches expected
guard decompressedPayload.count == originalSize else {
return nil
}
payload = decompressedPayload
} else {
// Uncompressed payload
guard Int(payloadLength) >= 0 && offset + Int(payloadLength) <= dataArray.count else {
return nil
}
payload = Data(dataArray[offset..<offset+Int(payloadLength)])
offset += Int(payloadLength)
}
// Signature if present
var signature: Data?
if hasSignature {
guard offset + signatureSize <= dataArray.count else { return nil }
signature = Data(dataArray[offset..<offset+signatureSize])
offset += signatureSize
}
// Final validation: ensure we haven't gone past the end
guard offset <= dataArray.count else { return nil }
return BitchatPacket(
type: type,
senderID: senderID,
recipientID: recipientID,
timestamp: timestamp,
payload: payload,
signature: signature,
ttl: ttl
)
}
}
guard raw.count >= v1HeaderSize + senderIDSize else { return nil }
// Binary encoding for BitchatMessage
extension BitchatMessage {
func toBinaryPayload() -> Data? {
var data = Data()
// Message format:
// - Flags: 1 byte (bit 0: isRelay, bit 1: isPrivate, bit 2: hasOriginalSender, bit 3: hasRecipientNickname, bit 4: hasSenderPeerID, bit 5: hasMentions)
// - Timestamp: 8 bytes (seconds since epoch)
// - ID length: 1 byte
// - ID: variable
// - Sender length: 1 byte
// - Sender: variable
// - Content length: 2 bytes
// - Content: variable
// Optional fields based on flags:
// - Original sender length + data
// - Recipient nickname length + data
// - Sender peer ID length + data
// - Mentions array
var flags: UInt8 = 0
if isRelay { flags |= 0x01 }
if isPrivate { flags |= 0x02 }
if originalSender != nil { flags |= 0x04 }
if recipientNickname != nil { flags |= 0x08 }
if senderPeerID != nil { flags |= 0x10 }
if mentions != nil && !mentions!.isEmpty { flags |= 0x20 }
data.append(flags)
// Timestamp (in milliseconds)
let timestampMillis = UInt64(timestamp.timeIntervalSince1970 * 1000)
// Encode as 8 bytes, big-endian
for i in (0..<8).reversed() {
data.append(UInt8((timestampMillis >> (i * 8)) & 0xFF))
}
// ID
if let idData = id.data(using: .utf8) {
data.append(UInt8(min(idData.count, 255)))
data.append(idData.prefix(255))
} else {
data.append(0)
}
// Sender
if let senderData = sender.data(using: .utf8) {
data.append(UInt8(min(senderData.count, 255)))
data.append(senderData.prefix(255))
} else {
data.append(0)
}
// Content
if let contentData = content.data(using: .utf8) {
let length = UInt16(min(contentData.count, 65535))
// Encode length as 2 bytes, big-endian
data.append(UInt8((length >> 8) & 0xFF))
data.append(UInt8(length & 0xFF))
data.append(contentData.prefix(Int(length)))
} else {
data.append(contentsOf: [0, 0])
}
// Optional fields
if let originalSender = originalSender, let origData = originalSender.data(using: .utf8) {
data.append(UInt8(min(origData.count, 255)))
data.append(origData.prefix(255))
}
if let recipientNickname = recipientNickname, let recipData = recipientNickname.data(using: .utf8) {
data.append(UInt8(min(recipData.count, 255)))
data.append(recipData.prefix(255))
}
if let senderPeerID = senderPeerID, let peerData = senderPeerID.data(using: .utf8) {
data.append(UInt8(min(peerData.count, 255)))
data.append(peerData.prefix(255))
}
// Mentions array
if let mentions = mentions {
data.append(UInt8(min(mentions.count, 255))) // Number of mentions
for mention in mentions.prefix(255) {
if let mentionData = mention.data(using: .utf8) {
data.append(UInt8(min(mentionData.count, 255)))
data.append(mentionData.prefix(255))
} else {
data.append(0)
}
return raw.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> BitchatPacket? in
guard let base = buf.baseAddress else { return nil }
var offset = 0
func require(_ n: Int) -> Bool { offset + n <= buf.count }
func read8() -> UInt8? {
guard require(1) else { return nil }
let value = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self).pointee
offset += 1
return value
}
}
return data
}
static func fromBinaryPayload(_ data: Data) -> BitchatMessage? {
// Create an immutable copy to prevent threading issues
let dataCopy = Data(data)
guard dataCopy.count >= 13 else {
return nil
}
var offset = 0
// Flags
guard offset < dataCopy.count else {
return nil
}
let flags = dataCopy[offset]; offset += 1
let isRelay = (flags & 0x01) != 0
let isPrivate = (flags & 0x02) != 0
let hasOriginalSender = (flags & 0x04) != 0
let hasRecipientNickname = (flags & 0x08) != 0
let hasSenderPeerID = (flags & 0x10) != 0
let hasMentions = (flags & 0x20) != 0
// Timestamp
guard offset + 8 <= dataCopy.count else {
return nil
}
let timestampData = dataCopy[offset..<offset+8]
let timestampMillis = timestampData.reduce(0) { result, byte in
(result << 8) | UInt64(byte)
}
offset += 8
let timestamp = Date(timeIntervalSince1970: TimeInterval(timestampMillis) / 1000.0)
// ID
guard offset < dataCopy.count else {
return nil
}
let idLength = Int(dataCopy[offset]); offset += 1
guard offset + idLength <= dataCopy.count else {
return nil
}
let id = String(data: dataCopy[offset..<offset+idLength], encoding: .utf8) ?? UUID().uuidString
offset += idLength
// Sender
guard offset < dataCopy.count else {
return nil
}
let senderLength = Int(dataCopy[offset]); offset += 1
guard offset + senderLength <= dataCopy.count else {
return nil
}
let sender = String(data: dataCopy[offset..<offset+senderLength], encoding: .utf8) ?? "unknown"
offset += senderLength
// Content
guard offset + 2 <= dataCopy.count else {
return nil
}
let contentLengthData = dataCopy[offset..<offset+2]
let contentLength = Int(contentLengthData.reduce(0) { result, byte in
(result << 8) | UInt16(byte)
})
offset += 2
guard offset + contentLength <= dataCopy.count else {
return nil
}
let content = String(data: dataCopy[offset..<offset+contentLength], encoding: .utf8) ?? ""
offset += contentLength
// Optional fields
var originalSender: String?
if hasOriginalSender && offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
originalSender = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
offset += length
func read16() -> UInt16? {
guard require(2) else { return nil }
let ptr = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
let value = (UInt16(ptr[0]) << 8) | UInt16(ptr[1])
offset += 2
return value
}
}
var recipientNickname: String?
if hasRecipientNickname && offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
recipientNickname = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
offset += length
func read32() -> UInt32? {
guard require(4) else { return nil }
let ptr = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
let value = (UInt32(ptr[0]) << 24) | (UInt32(ptr[1]) << 16) | (UInt32(ptr[2]) << 8) | UInt32(ptr[3])
offset += 4
return value
}
}
var senderPeerID: String?
if hasSenderPeerID && offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
senderPeerID = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
offset += length
func readData(_ n: Int) -> Data? {
guard require(n) else { return nil }
let ptr = base.advanced(by: offset)
let data = Data(bytes: ptr, count: n)
offset += n
return data
}
}
// Mentions array
var mentions: [String]?
if hasMentions && offset < dataCopy.count {
let mentionCount = Int(dataCopy[offset]); offset += 1
if mentionCount > 0 {
mentions = []
for _ in 0..<mentionCount {
if offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
if let mention = String(data: dataCopy[offset..<offset+length], encoding: .utf8) {
mentions?.append(mention)
}
offset += length
}
guard let version = read8(), version == 1 || version == 2 else { return nil }
let lengthFieldBytes = lengthFieldSize(for: version)
guard let headerSize = headerSize(for: version) else { return nil }
let minimumRequired = headerSize + senderIDSize
guard raw.count >= minimumRequired else { return nil }
guard let type = read8(), let ttl = read8() else { return nil }
var timestamp: UInt64 = 0
for _ in 0..<8 {
guard let byte = read8() else { return nil }
timestamp = (timestamp << 8) | UInt64(byte)
}
guard let flags = read8() else { return nil }
let hasRecipient = (flags & Flags.hasRecipient) != 0
let hasSignature = (flags & Flags.hasSignature) != 0
let isCompressed = (flags & Flags.isCompressed) != 0
let payloadLength: Int
if version == 2 {
guard let len = read32() else { return nil }
payloadLength = Int(len)
} else {
guard let len = read16() else { return nil }
payloadLength = Int(len)
}
guard payloadLength >= 0 else { return nil }
guard let senderID = readData(senderIDSize) else { return nil }
var recipientID: Data? = nil
if hasRecipient {
recipientID = readData(recipientIDSize)
if recipientID == nil { return nil }
}
var route: [Data]? = nil
var remainingPayloadBytes = payloadLength
if (flags & Flags.hasRoute) != 0 {
guard remainingPayloadBytes >= 1, let routeCount = read8() else { return nil }
remainingPayloadBytes -= 1
if routeCount > 0 {
var hops: [Data] = []
for _ in 0..<Int(routeCount) {
guard remainingPayloadBytes >= senderIDSize,
let hop = readData(senderIDSize) else { return nil }
remainingPayloadBytes -= senderIDSize
hops.append(hop)
}
route = hops
}
}
let payload: Data
if isCompressed {
guard remainingPayloadBytes >= lengthFieldBytes else { return nil }
let originalSize: Int
if version == 2 {
guard let rawSize = read32() else { return nil }
originalSize = Int(rawSize)
} else {
guard let rawSize = read16() else { return nil }
originalSize = Int(rawSize)
}
remainingPayloadBytes -= lengthFieldBytes
guard originalSize >= 0 && originalSize <= FileTransferLimits.maxFramedFileBytes else { return nil }
let compressedSize = remainingPayloadBytes
guard compressedSize > 0, let compressed = readData(compressedSize) else { return nil }
remainingPayloadBytes = 0
let compressionRatio = Double(originalSize) / Double(compressedSize)
guard compressionRatio <= 50_000.0 else {
SecureLogger.warning("🚫 Suspicious compression ratio: \(String(format: "%.0f", compressionRatio)):1", category: .security)
return nil
}
guard let decompressed = CompressionUtil.decompress(compressed, originalSize: originalSize),
decompressed.count == originalSize else { return nil }
payload = decompressed
} else {
guard remainingPayloadBytes >= 0,
let rawPayload = readData(remainingPayloadBytes) else { return nil }
remainingPayloadBytes = 0
payload = rawPayload
}
var signature: Data? = nil
if hasSignature {
signature = readData(signatureSize)
if signature == nil { return nil }
}
guard offset <= buf.count else { return nil }
return BitchatPacket(
type: type,
senderID: senderID,
recipientID: recipientID,
timestamp: timestamp,
payload: payload,
signature: signature,
ttl: ttl,
version: version,
route: route
)
}
let message = BitchatMessage(
id: id,
sender: sender,
content: content,
timestamp: timestamp,
isRelay: isRelay,
originalSender: originalSender,
isPrivate: isPrivate,
recipientNickname: recipientNickname,
senderPeerID: senderPeerID,
mentions: mentions
)
return message
}
}
+155
View File
@@ -0,0 +1,155 @@
//
// BitchatFilePacket.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
import BitLogger
/// TLV payload for Bluetooth mesh file transfers (voice notes, images, generic files).
/// Mirrors the Android client specification to ensure cross-platform interoperability.
struct BitchatFilePacket {
var fileName: String?
var fileSize: UInt64?
var mimeType: String?
var content: Data
/// Canonical TLV tags defined by the Android implementation.
private enum TLVType: UInt8 {
case fileName = 0x01
case fileSize = 0x02
case mimeType = 0x03
case content = 0x04
}
/// Encodes the packet using v2 canonical TLVs (4-byte FILE_SIZE, 4-byte CONTENT length).
/// Returns `nil` when fields exceed protocol limits (e.g., content > UInt32.max).
func encode() -> Data? {
let resolvedSize = fileSize ?? UInt64(content.count)
guard resolvedSize <= UInt64(UInt32.max) else { return nil }
guard resolvedSize <= UInt64(FileTransferLimits.maxPayloadBytes) else { return nil }
guard content.count <= Int(UInt32.max) else { return nil }
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
var big = value.bigEndian
withUnsafeBytes(of: &big) { data.append(contentsOf: $0) }
}
var encoded = Data()
if let name = fileName, let nameData = name.data(using: .utf8), nameData.count <= Int(UInt16.max) {
encoded.append(TLVType.fileName.rawValue)
appendBE(UInt16(nameData.count), into: &encoded)
encoded.append(nameData)
}
encoded.append(TLVType.fileSize.rawValue)
appendBE(UInt16(4), into: &encoded)
appendBE(UInt32(resolvedSize), into: &encoded)
if let mime = mimeType, let mimeData = mime.data(using: .utf8), mimeData.count <= Int(UInt16.max) {
encoded.append(TLVType.mimeType.rawValue)
appendBE(UInt16(mimeData.count), into: &encoded)
encoded.append(mimeData)
}
encoded.append(TLVType.content.rawValue)
appendBE(UInt32(content.count), into: &encoded)
encoded.append(content)
return encoded
}
/// Decodes TLV payloads, tolerating legacy encodings (FILE_SIZE len=8, CONTENT len=2) when possible.
static func decode(_ data: Data) -> BitchatFilePacket? {
var cursor = data.startIndex
let end = data.endIndex
var fileName: String?
var fileSize: UInt64?
var mimeType: String?
var content = Data()
while cursor < end {
let typeRaw = data[cursor]
cursor = data.index(after: cursor)
guard cursor <= end else { return nil }
let tlvType = TLVType(rawValue: typeRaw)
func readBigEndianLength(bytes: Int) -> Int? {
guard data.distance(from: cursor, to: end) >= bytes else { return nil }
// Use UInt64 to prevent integer overflow during shift operations
var result: UInt64 = 0
for _ in 0..<bytes {
result = (result << 8) | UInt64(data[cursor])
cursor = data.index(after: cursor)
}
// Safely convert to Int with overflow check
guard result <= Int.max else { return nil }
return Int(result)
}
let length: Int?
if tlvType == .content {
let snapshot = cursor
let canonical = readBigEndianLength(bytes: 4)
if let canonical = canonical,
canonical <= data.distance(from: cursor, to: end) {
length = canonical
} else {
cursor = snapshot
length = readBigEndianLength(bytes: 2)
}
} else {
length = readBigEndianLength(bytes: 2)
}
guard let tlvLength = length, tlvLength >= 0 else { return nil }
guard data.distance(from: cursor, to: end) >= tlvLength else { return nil }
let valueStart = cursor
cursor = data.index(cursor, offsetBy: tlvLength)
let value = data[valueStart..<cursor]
switch tlvType {
case .fileName:
fileName = String(data: Data(value), encoding: .utf8)
case .fileSize:
if tlvLength == 4 || tlvLength == 8 {
var size: UInt64 = 0
for byte in value {
size = (size << 8) | UInt64(byte)
}
if size > UInt64(FileTransferLimits.maxPayloadBytes) {
return nil
}
fileSize = size
}
case .mimeType:
mimeType = String(data: Data(value), encoding: .utf8)
case .content:
let proposedSize = content.count + value.count
if proposedSize > FileTransferLimits.maxPayloadBytes {
return nil
}
content.append(contentsOf: value)
case nil:
continue
}
}
guard !content.isEmpty else { return nil }
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
return BitchatFilePacket(
fileName: fileName,
fileSize: fileSize ?? UInt64(content.count),
mimeType: mimeType,
content: content
)
}
}
+18 -348
View File
@@ -59,61 +59,7 @@
///
import Foundation
import CryptoKit
// MARK: - Message Padding
/// Provides privacy-preserving message padding to obscure actual content length.
/// Uses PKCS#7-style padding with random bytes to prevent traffic analysis.
struct MessagePadding {
// Standard block sizes for padding
static let blockSizes = [256, 512, 1024, 2048]
// Add PKCS#7-style padding to reach target size
static func pad(_ data: Data, toSize targetSize: Int) -> Data {
guard data.count < targetSize else { return data }
let paddingNeeded = targetSize - data.count
// Constrain to 255 to fit a single-byte pad length marker
guard paddingNeeded > 0 && paddingNeeded <= 255 else { return data }
var padded = data
// PKCS#7: All pad bytes are equal to the pad length
padded.append(contentsOf: Array(repeating: UInt8(paddingNeeded), count: paddingNeeded))
return padded
}
// Remove padding from data
static func unpad(_ data: Data) -> Data {
guard !data.isEmpty else { return data }
let last = data.last!
let paddingLength = Int(last)
// Must have at least 1 pad byte and not exceed data length
guard paddingLength > 0 && paddingLength <= data.count else { return data }
// Verify PKCS#7: all last N bytes equal to pad length
let start = data.count - paddingLength
let tail = data[start...]
for b in tail { if b != last { return data } }
return Data(data[..<start])
}
// Find optimal block size for data
static func optimalBlockSize(for dataSize: Int) -> Int {
// Account for encryption overhead (~16 bytes for AES-GCM tag)
let totalSize = dataSize + 16
// Find smallest block that fits
for blockSize in blockSizes {
if totalSize <= blockSize {
return blockSize
}
}
// For very large messages, just use the original size
// (will be fragmented anyway)
return dataSize
}
}
import CoreBluetooth
// MARK: - Message Types
@@ -125,6 +71,7 @@ enum MessageType: UInt8 {
case announce = 0x01 // "I'm here" with nickname
case message = 0x02 // Public chat message
case leave = 0x03 // "I'm leaving"
case requestSync = 0x21 // GCS filter-based sync request (local-only)
// Noise encryption
case noiseHandshake = 0x10 // Handshake (init or response determined by payload)
@@ -132,15 +79,18 @@ enum MessageType: UInt8 {
// Fragmentation (simplified)
case fragment = 0x20 // Single fragment type for large messages
case fileTransfer = 0x22 // Binary file/audio/image payloads
var description: String {
switch self {
case .announce: return "announce"
case .message: return "message"
case .leave: return "leave"
case .requestSync: return "requestSync"
case .noiseHandshake: return "noiseHandshake"
case .noiseEncrypted: return "noiseEncrypted"
case .fragment: return "fragment"
case .fileTransfer: return "fileTransfer"
}
}
}
@@ -181,191 +131,10 @@ enum LazyHandshakeState {
case failed(Error) // Handshake failed
}
//
// MARK: - Core Protocol Structures
/// The core packet structure for all BitChat protocol messages.
/// Encapsulates all data needed for routing through the mesh network,
/// including TTL for hop limiting and optional encryption.
/// - Note: Packets larger than BLE MTU (512 bytes) are automatically fragmented
struct BitchatPacket: Codable {
let version: UInt8
let type: UInt8
let senderID: Data
let recipientID: Data?
let timestamp: UInt64
let payload: Data
var signature: Data?
var ttl: UInt8
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8) {
self.version = 1
self.type = type
self.senderID = senderID
self.recipientID = recipientID
self.timestamp = timestamp
self.payload = payload
self.signature = signature
self.ttl = ttl
}
// Convenience initializer for new binary format
init(type: UInt8, ttl: UInt8, senderID: String, payload: Data) {
self.version = 1
self.type = type
// Convert hex string peer ID to binary data (8 bytes)
var senderData = Data()
var tempID = senderID
while tempID.count >= 2 {
let hexByte = String(tempID.prefix(2))
if let byte = UInt8(hexByte, radix: 16) {
senderData.append(byte)
}
tempID = String(tempID.dropFirst(2))
}
self.senderID = senderData
self.recipientID = nil
self.timestamp = UInt64(Date().timeIntervalSince1970 * 1000) // milliseconds
self.payload = payload
self.signature = nil
self.ttl = ttl
}
var data: Data? {
BinaryProtocol.encode(self)
}
func toBinaryData(padding: Bool = true) -> Data? {
BinaryProtocol.encode(self, padding: padding)
}
// Backward-compatible helper (defaults to padded encoding)
func toBinaryData() -> Data? {
toBinaryData(padding: true)
}
/// Create binary representation for signing (without signature and TTL fields)
/// TTL is excluded because it changes during packet relay operations
func toBinaryDataForSigning() -> Data? {
// Create a copy without signature and with fixed TTL for signing
// TTL must be excluded because it changes during relay
let unsignedPacket = BitchatPacket(
type: type,
senderID: senderID,
recipientID: recipientID,
timestamp: timestamp,
payload: payload,
signature: nil, // Remove signature for signing
ttl: 0 // Use fixed TTL=0 for signing to ensure relay compatibility
)
return BinaryProtocol.encode(unsignedPacket)
}
static func from(_ data: Data) -> BitchatPacket? {
BinaryProtocol.decode(data)
}
}
//
// MARK: - Read Receipts
// Read receipt structure
struct ReadReceipt: Codable {
let originalMessageID: String
let receiptID: String
var readerID: String // Who read it
let readerNickname: String
let timestamp: Date
init(originalMessageID: String, readerID: String, readerNickname: String) {
self.originalMessageID = originalMessageID
self.receiptID = UUID().uuidString
self.readerID = readerID
self.readerNickname = readerNickname
self.timestamp = Date()
}
// For binary decoding
private init(originalMessageID: String, receiptID: String, readerID: String, readerNickname: String, timestamp: Date) {
self.originalMessageID = originalMessageID
self.receiptID = receiptID
self.readerID = readerID
self.readerNickname = readerNickname
self.timestamp = timestamp
}
func encode() -> Data? {
try? JSONEncoder().encode(self)
}
static func decode(from data: Data) -> ReadReceipt? {
try? JSONDecoder().decode(ReadReceipt.self, from: data)
}
// MARK: - Binary Encoding
func toBinaryData() -> Data {
var data = Data()
data.appendUUID(originalMessageID)
data.appendUUID(receiptID)
// ReaderID as 8-byte hex string
var readerData = Data()
var tempID = readerID
while tempID.count >= 2 && readerData.count < 8 {
let hexByte = String(tempID.prefix(2))
if let byte = UInt8(hexByte, radix: 16) {
readerData.append(byte)
}
tempID = String(tempID.dropFirst(2))
}
while readerData.count < 8 {
readerData.append(0)
}
data.append(readerData)
data.appendDate(timestamp)
data.appendString(readerNickname)
return data
}
static func fromBinaryData(_ data: Data) -> ReadReceipt? {
// Create defensive copy
let dataCopy = Data(data)
// Minimum size: 2 UUIDs (32) + readerID (8) + timestamp (8) + min nickname
guard dataCopy.count >= 49 else { return nil }
var offset = 0
guard let originalMessageID = dataCopy.readUUID(at: &offset),
let receiptID = dataCopy.readUUID(at: &offset) else { return nil }
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
let readerID = readerIDData.hexEncodedString()
guard InputValidator.validatePeerID(readerID) else { return nil }
guard let timestamp = dataCopy.readDate(at: &offset),
InputValidator.validateTimestamp(timestamp),
let readerNicknameRaw = dataCopy.readString(at: &offset),
let readerNickname = InputValidator.validateNickname(readerNicknameRaw) else { return nil }
return ReadReceipt(originalMessageID: originalMessageID,
receiptID: receiptID,
readerID: readerID,
readerNickname: readerNickname,
timestamp: timestamp)
}
}
//
// MARK: - Delivery Status
// Delivery status for messages
enum DeliveryStatus: Codable, Equatable {
enum DeliveryStatus: Codable, Equatable, Hashable {
case sending
case sent // Left our device
case delivered(to: String, at: Date) // Confirmed by recipient
@@ -391,90 +160,25 @@ enum DeliveryStatus: Codable, Equatable {
}
}
// MARK: - Message Model
/// Represents a user-visible message in the BitChat system.
/// Handles both broadcast messages and private encrypted messages,
/// with support for mentions, replies, and delivery tracking.
/// - Note: This is the primary data model for chat messages
class BitchatMessage: Codable {
let id: String
let sender: String
let content: String
let timestamp: Date
let isRelay: Bool
let originalSender: String?
let isPrivate: Bool
let recipientNickname: String?
let senderPeerID: String?
let mentions: [String]? // Array of mentioned nicknames
var deliveryStatus: DeliveryStatus? // Delivery tracking
// Cached formatted text (not included in Codable)
private var _cachedFormattedText: [String: AttributedString] = [:]
func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? {
return _cachedFormattedText["\(isDark)-\(isSelf)"]
}
func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool) {
_cachedFormattedText["\(isDark)-\(isSelf)"] = text
}
// Codable implementation
enum CodingKeys: String, CodingKey {
case id, sender, content, timestamp, isRelay, originalSender
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus
}
init(id: String? = nil, sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil, deliveryStatus: DeliveryStatus? = nil) {
self.id = id ?? UUID().uuidString
self.sender = sender
self.content = content
self.timestamp = timestamp
self.isRelay = isRelay
self.originalSender = originalSender
self.isPrivate = isPrivate
self.recipientNickname = recipientNickname
self.senderPeerID = senderPeerID
self.mentions = mentions
self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil)
}
}
// Equatable conformance for BitchatMessage
extension BitchatMessage: Equatable {
static func == (lhs: BitchatMessage, rhs: BitchatMessage) -> Bool {
return lhs.id == rhs.id &&
lhs.sender == rhs.sender &&
lhs.content == rhs.content &&
lhs.timestamp == rhs.timestamp &&
lhs.isRelay == rhs.isRelay &&
lhs.originalSender == rhs.originalSender &&
lhs.isPrivate == rhs.isPrivate &&
lhs.recipientNickname == rhs.recipientNickname &&
lhs.senderPeerID == rhs.senderPeerID &&
lhs.mentions == rhs.mentions &&
lhs.deliveryStatus == rhs.deliveryStatus
}
}
// MARK: - Delegate Protocol
protocol BitchatDelegate: AnyObject {
func didReceiveMessage(_ message: BitchatMessage)
func didConnectToPeer(_ peerID: String)
func didDisconnectFromPeer(_ peerID: String)
func didUpdatePeerList(_ peers: [String])
func didConnectToPeer(_ peerID: PeerID)
func didDisconnectFromPeer(_ peerID: PeerID)
func didUpdatePeerList(_ peers: [PeerID])
// Optional method to check if a fingerprint belongs to a favorite peer
func isFavorite(fingerprint: String) -> Bool
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus)
// Low-level events for better separation of concerns
func didReceiveNoisePayload(from peerID: String, type: NoisePayloadType, payload: Data, timestamp: Date)
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date)
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
// Bluetooth state updates for user notifications
func didUpdateBluetoothState(_ state: CBManagerState)
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
}
// Provide default implementation to make it effectively optional
@@ -487,45 +191,11 @@ extension BitchatDelegate {
// Default empty implementation
}
func didReceiveNoisePayload(from peerID: String, type: NoisePayloadType, payload: Data, timestamp: Date) {
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {
// Default empty implementation
}
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) {
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
// Default empty implementation
}
}
// MARK: - Noise Payload Helpers
/// Helper to create typed Noise payloads
struct NoisePayload {
let type: NoisePayloadType
let data: Data
/// Encode payload with type prefix
func encode() -> Data {
var encoded = Data()
encoded.append(type.rawValue)
encoded.append(data)
return encoded
}
/// Decode payload from data
static func decode(_ data: Data) -> NoisePayload? {
// Ensure we have at least 1 byte for the type
guard !data.isEmpty else {
return nil
}
// Safely get the first byte
let firstByte = data[data.startIndex]
guard let type = NoisePayloadType(rawValue: firstByte) else {
return nil
}
// Create a proper Data copy (not a subsequence) for thread safety
let payloadData = data.count > 1 ? Data(data.dropFirst()) : Data()
return NoisePayload(type: type, data: payloadData)
}
}
+61
View File
@@ -10,6 +10,14 @@ enum Geohash {
return map
}()
/// Validates a geohash string for building-level precision (8 characters).
/// - Parameter geohash: The geohash string to validate
/// - Returns: true if valid 8-character base32 geohash, false otherwise
static func isValidBuildingGeohash(_ geohash: String) -> Bool {
guard geohash.count == 8 else { return false }
return geohash.lowercased().allSatisfy { base32Map[$0] != nil }
}
/// Encodes the provided coordinates into a geohash string.
/// - Parameters:
/// - latitude: Latitude in degrees (-90...90)
@@ -111,4 +119,57 @@ enum Geohash {
}
return (latInterval.0, latInterval.1, lonInterval.0, lonInterval.1)
}
/// Returns all 8 neighboring geohash cells at the same precision.
/// - Parameter geohash: Base32 geohash string.
/// - Returns: Array of 8 neighboring geohashes (N, NE, E, SE, S, SW, W, NW order).
static func neighbors(of geohash: String) -> [String] {
guard !geohash.isEmpty else { return [] }
let precision = geohash.count
let bounds = decodeBounds(geohash)
let center = decodeCenter(geohash)
// Calculate cell dimensions
let latHeight = bounds.latMax - bounds.latMin
let lonWidth = bounds.lonMax - bounds.lonMin
// Helper to wrap longitude around ±180
func wrapLongitude(_ lon: Double) -> Double {
var wrapped = lon
while wrapped > 180.0 { wrapped -= 360.0 }
while wrapped < -180.0 { wrapped += 360.0 }
return wrapped
}
// Helper to clamp latitude to ±90
func clampLatitude(_ lat: Double) -> Double {
return max(-90.0, min(90.0, lat))
}
// Calculate 8 neighbor centers
let neighbors: [(lat: Double, lon: Double)] = [
(center.lat + latHeight, center.lon), // N
(center.lat + latHeight, center.lon + lonWidth), // NE
(center.lat, center.lon + lonWidth), // E
(center.lat - latHeight, center.lon + lonWidth), // SE
(center.lat - latHeight, center.lon), // S
(center.lat - latHeight, center.lon - lonWidth), // SW
(center.lat, center.lon - lonWidth), // W
(center.lat + latHeight, center.lon - lonWidth) // NW
]
// Encode each neighbor, handling boundary conditions
return neighbors.compactMap { neighbor in
let lat = clampLatitude(neighbor.lat)
let lon = wrapLongitude(neighbor.lon)
// Skip if we've crossed a pole (latitude clamped to boundary)
if (neighbor.lat > 90.0 || neighbor.lat < -90.0) {
return nil
}
return encode(latitude: lat, longitude: lon, precision: precision)
}
}
}
+32 -6
View File
@@ -2,6 +2,7 @@ import Foundation
/// Levels of location channels mapped to geohash precisions.
enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
case building
case block
case neighborhood
case city
@@ -11,6 +12,7 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
/// Geohash length used for this level.
var precision: Int {
switch self {
case .building: return 8
case .block: return 7
case .neighborhood: return 6
case .city: return 5
@@ -21,20 +23,28 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
var displayName: String {
switch self {
case .block: return "Block"
case .neighborhood: return "Neighborhood"
case .city: return "City"
case .province: return "Province"
case .region: return "Region"
case .building:
return String(localized: "location_levels.building", comment: "Name for building-level location channel")
case .block:
return String(localized: "location_levels.block", comment: "Name for block-level location channel")
case .neighborhood:
return String(localized: "location_levels.neighborhood", comment: "Name for neighborhood-level location channel")
case .city:
return String(localized: "location_levels.city", comment: "Name for city-level location channel")
case .province:
return String(localized: "location_levels.province", comment: "Name for province-level location channel")
case .region:
return String(localized: "location_levels.region", comment: "Name for region-level location channel")
}
}
}
}
// Backward-compatible Codable for renamed cases
extension GeohashChannelLevel {
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let raw = try? container.decode(String.self) {
switch raw {
case "building": self = .building
case "block": self = .block
case "neighborhood": self = .neighborhood
case "city": self = .city
@@ -46,6 +56,7 @@ extension GeohashChannelLevel {
}
} else if let precision = try? container.decode(Int.self) {
switch precision {
case 8: self = .building
case 7: self = .block
case 6: self = .neighborhood
case 5: self = .city
@@ -61,6 +72,7 @@ extension GeohashChannelLevel {
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .building: try container.encode("building")
case .block: try container.encode("block")
case .neighborhood: try container.encode("neighborhood")
case .city: try container.encode("city")
@@ -104,4 +116,18 @@ enum ChannelID: Equatable, Codable {
case .location(let ch): return ch.geohash
}
}
var isMesh: Bool {
switch self {
case .mesh: true
case .location: false
}
}
var isLocation: Bool {
switch self {
case .mesh: false
case .location: true
}
}
}
+29 -1
View File
@@ -6,15 +6,19 @@ struct AnnouncementPacket {
let nickname: String
let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement)
let signingPublicKey: Data // Ed25519 public key for signing
let directNeighbors: [Data]? // 8-byte peer IDs
private enum TLVType: UInt8 {
case nickname = 0x01
case noisePublicKey = 0x02
case signingPublicKey = 0x03
case directNeighbors = 0x04
}
func encode() -> Data? {
var data = Data()
// Reserve: TLVs for nickname (2 + n), noise key (2 + 32), signing key (2 + 32)
data.reserveCapacity(2 + min(nickname.count, 255) + 2 + noisePublicKey.count + 2 + signingPublicKey.count)
// TLV for nickname
guard let nicknameData = nickname.data(using: .utf8), nicknameData.count <= 255 else { return nil }
@@ -33,6 +37,16 @@ struct AnnouncementPacket {
data.append(TLVType.signingPublicKey.rawValue)
data.append(UInt8(signingPublicKey.count))
data.append(signingPublicKey)
// TLV for direct neighbors (optional)
if let neighbors = directNeighbors, !neighbors.isEmpty {
let neighborsData = neighbors.prefix(10).reduce(Data()) { $0 + $1 }
if !neighborsData.isEmpty && neighborsData.count % 8 == 0 {
data.append(TLVType.directNeighbors.rawValue)
data.append(UInt8(neighborsData.count))
data.append(neighborsData)
}
}
return data
}
@@ -42,6 +56,7 @@ struct AnnouncementPacket {
var nickname: String?
var noisePublicKey: Data?
var signingPublicKey: Data?
var directNeighbors: [Data]?
while offset + 2 <= data.count {
let typeRaw = data[offset]
@@ -61,6 +76,17 @@ struct AnnouncementPacket {
noisePublicKey = Data(value)
case .signingPublicKey:
signingPublicKey = Data(value)
case .directNeighbors:
if length > 0 && length % 8 == 0 {
var neighbors = [Data]()
let count = length / 8
for i in 0..<count {
let start = value.startIndex + i * 8
let end = start + 8
neighbors.append(Data(value[start..<end]))
}
directNeighbors = neighbors
}
}
} else {
// Unknown TLV; skip (tolerant decoder for forward compatibility)
@@ -72,7 +98,8 @@ struct AnnouncementPacket {
return AnnouncementPacket(
nickname: nickname,
noisePublicKey: noisePublicKey,
signingPublicKey: signingPublicKey
signingPublicKey: signingPublicKey,
directNeighbors: directNeighbors
)
}
}
@@ -88,6 +115,7 @@ struct PrivateMessagePacket {
func encode() -> Data? {
var data = Data()
data.reserveCapacity(2 + min(messageID.count, 255) + 2 + min(content.count, 255))
// TLV for messageID
guard let messageIDData = messageID.data(using: .utf8), messageIDData.count <= 255 else { return nil }
-14
View File
@@ -1,14 +0,0 @@
import Foundation
import CryptoKit
// MARK: - Peer ID Utilities
struct PeerIDUtils {
/// Derive the stable 16-hex peer ID from a Noise static public key
static func derivePeerID(fromPublicKey publicKey: Data) -> String {
let digest = SHA256.hash(data: publicKey)
let hex = digest.map { String(format: "%02x", $0) }.joined()
return String(hex.prefix(16))
}
}
+1 -1
View File
@@ -9,7 +9,7 @@
import Foundation
/// Manages autocomplete functionality for chat
class AutocompleteService {
final class AutocompleteService {
private let mentionRegex = try? NSRegularExpression(pattern: "@([\\p{L}0-9_]*)$", options: [])
private let commandRegex = try? NSRegularExpression(pattern: "^/([a-z]*)$", options: [])
File diff suppressed because it is too large Load Diff
+195
View File
@@ -0,0 +1,195 @@
//
// MimeType.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import UniformTypeIdentifiers
// MARK: - Extensions for missing UTTypes
extension UTType {
static let webP = UTType(importedAs: "image/webp")
static let aac = UTType(importedAs: "audio/aac")
static let m4a = UTType(importedAs: "audio/m4a")
static let ogg = UTType(importedAs: "audio/ogg")
}
// MARK: - MimeType Enum
enum MimeType: CaseIterable, Hashable {
case jpeg
case jpg
case png
case gif
case webp
case mp4Audio
case m4a
case aac
case mpeg
case mp3
case wav
case xWav
case ogg
case pdf
case octetStream
var utType: UTType {
switch self {
case .jpeg, .jpg: .jpeg
case .png: .png
case .gif: .gif
case .webp: .webP
case .aac: .aac
case .m4a: .m4a
case .mp4Audio: .mpeg4Audio
case .mp3, .mpeg: .mp3
case .wav, .xWav: .wav
case .ogg: .ogg
case .pdf: .pdf
case .octetStream: .data
}
}
var category: Category {
switch self {
case .jpeg, .jpg, .png, .gif, .webp:
return .image
case .aac, .m4a, .mp4Audio, .mpeg, .mp3, .wav, .xWav, .ogg:
return .audio
case .pdf, .octetStream:
return .file
}
}
var mimeString: String {
switch self {
case .jpeg, .jpg: "image/jpeg"
case .png: "image/png"
case .gif: "image/gif"
case .webp: "image/webp"
case .mp4Audio: "audio/mp4"
case .m4a: "audio/m4a"
case .aac: "audio/aac"
case .mpeg: "audio/mpeg"
case .mp3: "audio/mp3"
case .wav: "audio/wav"
case .xWav: "audio/x-wav"
case .ogg: "audio/ogg"
case .pdf: "application/pdf"
case .octetStream: "application/octet-stream"
}
}
var defaultExtension: String {
switch self {
case .jpeg, .jpg: "jpg"
case .png: "png"
case .webp: "webp"
case .gif: "gif"
case .mp4Audio, .m4a, .aac: "m4a"
case .mpeg, .mp3: "mp3"
case .wav, .xWav: "wav"
case .ogg: "ogg"
case .pdf: "pdf"
case .octetStream: "bin"
}
}
static var allowed: Set<MimeType> = [
.jpeg, .jpg, .png, .gif, .webp,
.mp4Audio, .m4a, .aac, .mpeg, .mp3,
.wav, .xWav, .ogg,
.pdf, .octetStream
]
var isAllowed: Bool {
Self.allowed.contains(self)
}
// MARK: - Byte signature validation
func matches(data: Data) -> Bool {
guard !data.isEmpty else { return false }
// Generic type skip validation
if self == .octetStream { return true }
switch self {
case .jpeg, .jpg:
return data.count >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF
case .png:
return data.count >= 8 &&
data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 &&
data[4] == 0x0D && data[5] == 0x0A && data[6] == 0x1A && data[7] == 0x0A
case .gif:
return data.count >= 6 && data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 &&
data[3] == 0x38 && (data[4] == 0x37 || data[4] == 0x39) && data[5] == 0x61
case .webp:
return data.count >= 12 &&
data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 &&
data[8] == 0x57 && data[9] == 0x45 && data[10] == 0x42 && data[11] == 0x50
case .m4a, .mp4Audio, .aac:
// AVAudioRecorder output varies by platform - be lenient
// Security: size already capped + sandboxed execution
return data.count > 100
case .mpeg, .mp3:
if data.count >= 3 && data[0] == 0x49 && data[1] == 0x44 && data[2] == 0x33 {
return true // ID3 header
}
return data.count >= 2 && data[0] == 0xFF && (data[1] & 0xE0) == 0xE0
case .wav, .xWav:
return data.count >= 12 &&
data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 &&
data[8] == 0x57 && data[9] == 0x41 && data[10] == 0x56 && data[11] == 0x45
case .ogg:
return data.count >= 4 &&
data[0] == 0x4F && data[1] == 0x67 && data[2] == 0x67 && data[3] == 0x53
case .pdf:
return data.count >= 4 &&
data[0] == 0x25 && data[1] == 0x50 && data[2] == 0x44 && data[3] == 0x46
default:
return false
}
}
// MARK: - Convenience Initializers
init?(_ mimeString: String?) {
guard let mimeString else { return nil }
let normalized = mimeString.lowercased()
// Direct match with our canonical list
if let match = MimeType.allCases.first(where: { $0.mimeString == normalized }) {
self = match
return
}
// Let UTType normalize aliases like "image/jpg", "audio/x-wav", etc.
if let type = UTType(mimeType: normalized),
let match = MimeType.allCases.first(where: { type.conforms(to: $0.utType) }) {
self = match
return
}
return nil
}
}
extension MimeType {
enum Category: String {
case audio, image, file
}
}
+105 -73
View File
@@ -15,15 +15,65 @@ enum CommandResult {
case handled // Command handled, no message needed
}
/// Simple struct for geo participant info used by CommandProcessor
struct CommandGeoParticipant {
let id: String // pubkey hex (lowercased)
let displayName: String
}
/// Protocol defining what CommandProcessor needs from its context.
/// This breaks the circular dependency between CommandProcessor and ChatViewModel.
@MainActor
protocol CommandContextProvider: AnyObject {
// MARK: - State Properties
var nickname: String { get }
var selectedPrivateChatPeer: PeerID? { get }
var blockedUsers: Set<String> { get }
var privateChats: [PeerID: [BitchatMessage]] { get set }
var idBridge: NostrIdentityBridge { get }
// MARK: - Peer Lookup
func getPeerIDForNickname(_ nickname: String) -> PeerID?
func getVisibleGeoParticipants() -> [CommandGeoParticipant]
func nostrPubkeyForDisplayName(_ displayName: String) -> String?
// MARK: - Chat Actions
func startPrivateChat(with peerID: PeerID)
func sendPrivateMessage(_ content: String, to peerID: PeerID)
func clearCurrentPublicTimeline()
func sendPublicRaw(_ content: String)
// MARK: - System Messages
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID)
func addPublicSystemMessage(_ content: String)
// MARK: - Favorites
func toggleFavorite(peerID: PeerID)
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
}
/// Processes chat commands in a focused, efficient way
@MainActor
class CommandProcessor {
weak var chatViewModel: ChatViewModel?
final class CommandProcessor {
weak var contextProvider: CommandContextProvider?
weak var meshService: Transport?
init(chatViewModel: ChatViewModel? = nil, meshService: Transport? = nil) {
self.chatViewModel = chatViewModel
private let identityManager: SecureIdentityStateManagerProtocol
/// Backward-compatible property for existing code
weak var chatViewModel: CommandContextProvider? {
get { contextProvider }
set { contextProvider = newValue }
}
init(contextProvider: CommandContextProvider? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {
self.contextProvider = contextProvider
self.meshService = meshService
self.identityManager = identityManager
}
/// Backward-compatible initializer
convenience init(chatViewModel: ChatViewModel? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {
self.init(contextProvider: chatViewModel, meshService: meshService, identityManager: identityManager)
}
/// Process a command string
@@ -40,7 +90,7 @@ class CommandProcessor {
case .location: return true
}
}()
let inGeoDM = (chatViewModel?.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
let inGeoDM = contextProvider?.selectedPrivateChatPeer?.isGeoDM == true
switch cmd {
case "/m", "/msg":
@@ -50,9 +100,9 @@ class CommandProcessor {
case "/clear":
return handleClear()
case "/hug":
return handleEmote(args, action: "hugs", emoji: "🫂")
return handleEmote(args, command: "hug", action: "hugs", emoji: "🫂")
case "/slap":
return handleEmote(args, action: "slaps", emoji: "🐟", suffix: " around a bit with a large trout")
return handleEmote(args, command: "slap", action: "slaps", emoji: "🐟", suffix: " around a bit with a large trout")
case "/block":
return handleBlock(args)
case "/unblock":
@@ -63,14 +113,11 @@ class CommandProcessor {
case "/unfav":
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
return handleFavorite(args, add: false)
//
case "/help", "/h":
return .error(message: "unknown command: \(cmd)")
default:
return .error(message: "unknown command: \(cmd)")
}
}
// MARK: - Command Handlers
private func handleMessage(_ args: String) -> CommandResult {
@@ -82,15 +129,15 @@ class CommandProcessor {
let targetName = String(parts[0])
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname) else {
guard let peerID = contextProvider?.getPeerIDForNickname(nickname) else {
return .error(message: "'\(nickname)' not found")
}
chatViewModel?.startPrivateChat(with: peerID)
contextProvider?.startPrivateChat(with: peerID)
if parts.count > 1 {
let message = String(parts[1])
chatViewModel?.sendPrivateMessage(message, to: peerID)
contextProvider?.sendPrivateMessage(message, to: peerID)
}
return .success(message: "started private chat with \(nickname)")
@@ -101,9 +148,9 @@ class CommandProcessor {
switch LocationChannelManager.shared.selectedChannel {
case .location(let ch):
// Geohash context: show visible geohash participants (exclude self)
guard let vm = chatViewModel else { return .success(message: "nobody around") }
let myHex = (try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
let people = vm.visibleGeohashPeople().filter { person in
guard let vm = contextProvider else { return .success(message: "nobody around") }
let myHex = (try? vm.idBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
let people = vm.getVisibleGeoParticipants().filter { person in
if let me = myHex { return person.id.lowercased() != me }
return true
}
@@ -121,35 +168,35 @@ class CommandProcessor {
}
private func handleClear() -> CommandResult {
if let peerID = chatViewModel?.selectedPrivateChatPeer {
chatViewModel?.privateChats[peerID]?.removeAll()
if let peerID = contextProvider?.selectedPrivateChatPeer {
contextProvider?.privateChats[peerID]?.removeAll()
} else {
chatViewModel?.clearCurrentPublicTimeline()
contextProvider?.clearCurrentPublicTimeline()
}
return .handled
}
private func handleEmote(_ args: String, action: String, emoji: String, suffix: String = "") -> CommandResult {
private func handleEmote(_ args: String, command: String, action: String, emoji: String, suffix: String = "") -> CommandResult {
let targetName = args.trimmingCharacters(in: .whitespaces)
guard !targetName.isEmpty else {
return .error(message: "usage: /\(action) <nickname>")
return .error(message: "usage: /\(command) <nickname>")
}
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
guard let targetPeerID = chatViewModel?.getPeerIDForNickname(nickname),
let myNickname = chatViewModel?.nickname else {
return .error(message: "cannot \(action) \(nickname): not found")
guard let targetPeerID = contextProvider?.getPeerIDForNickname(nickname),
let myNickname = contextProvider?.nickname else {
return .error(message: "cannot \(command) \(nickname): not found")
}
let emoteContent = "* \(emoji) \(myNickname) \(action) \(nickname)\(suffix) *"
if chatViewModel?.selectedPrivateChatPeer != nil {
if contextProvider?.selectedPrivateChatPeer != nil {
// In private chat
if let peerNickname = meshService?.peerNickname(peerID: targetPeerID) {
let personalMessage = "* \(emoji) \(myNickname) \(action) you\(suffix) *"
meshService?.sendPrivateMessage(personalMessage, to: targetPeerID,
recipientNickname: peerNickname,
meshService?.sendPrivateMessage(personalMessage, to: targetPeerID,
recipientNickname: peerNickname,
messageID: UUID().uuidString)
// Also add a local system message so the sender sees a natural-language confirmation
let pastAction: String = {
@@ -160,13 +207,13 @@ class CommandProcessor {
}
}()
let localText = "\(emoji) you \(pastAction) \(nickname)\(suffix)"
chatViewModel?.addLocalPrivateSystemMessage(localText, to: targetPeerID)
contextProvider?.addLocalPrivateSystemMessage(localText, to: targetPeerID)
}
} else {
// In public chat: send to active public channel (mesh or geohash)
chatViewModel?.sendPublicRaw(emoteContent)
contextProvider?.sendPublicRaw(emoteContent)
let publicEcho = "\(emoji) \(myNickname) \(action) \(nickname)\(suffix)"
chatViewModel?.addPublicSystemMessage(publicEcho)
contextProvider?.addPublicSystemMessage(publicEcho)
}
return .handled
@@ -177,7 +224,7 @@ class CommandProcessor {
if targetName.isEmpty {
// List blocked users (mesh) and geohash (Nostr) blocks
let meshBlocked = chatViewModel?.blockedUsers ?? []
let meshBlocked = contextProvider?.blockedUsers ?? []
var blockedNicknames: [String] = []
if let peers = meshService?.getPeerNicknames() {
for (peerID, nickname) in peers {
@@ -189,10 +236,10 @@ class CommandProcessor {
}
// Geohash blocked names (prefer visible display names; fallback to #suffix)
let geoBlocked = Array(SecureIdentityStateManager.shared.getBlockedNostrPubkeys())
let geoBlocked = Array(identityManager.getBlockedNostrPubkeys())
var geoNames: [String] = []
if let vm = chatViewModel {
let visible = vm.visibleGeohashPeople()
if let vm = contextProvider {
let visible = vm.getVisibleGeoParticipants()
let visibleIndex = Dictionary(uniqueKeysWithValues: visible.map { ($0.id.lowercased(), $0.displayName) })
for pk in geoBlocked {
if let name = visibleIndex[pk.lowercased()] {
@@ -211,16 +258,16 @@ class CommandProcessor {
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
if let peerID = contextProvider?.getPeerIDForNickname(nickname),
let fingerprint = meshService?.getFingerprint(for: peerID) {
if SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) {
if identityManager.isBlocked(fingerprint: fingerprint) {
return .success(message: "\(nickname) is already blocked")
}
// 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.isFavorite = false
SecureIdentityStateManager.shared.updateSocialIdentity(identity)
identityManager.updateSocialIdentity(identity)
} else {
let blockedIdentity = SocialIdentity(
fingerprint: fingerprint,
@@ -231,16 +278,16 @@ class CommandProcessor {
isBlocked: true,
notes: nil
)
SecureIdentityStateManager.shared.updateSocialIdentity(blockedIdentity)
identityManager.updateSocialIdentity(blockedIdentity)
}
return .success(message: "blocked \(nickname). you will no longer receive messages from them")
}
// Mesh lookup failed; try geohash (Nostr) participant by display name
if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) {
if SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: pub) {
if let pub = contextProvider?.nostrPubkeyForDisplayName(nickname) {
if identityManager.isNostrBlocked(pubkeyHexLowercased: pub) {
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")
}
@@ -255,20 +302,20 @@ class CommandProcessor {
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
if let peerID = contextProvider?.getPeerIDForNickname(nickname),
let fingerprint = meshService?.getFingerprint(for: peerID) {
if !SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) {
if !identityManager.isBlocked(fingerprint: fingerprint) {
return .success(message: "\(nickname) is not blocked")
}
SecureIdentityStateManager.shared.setBlocked(fingerprint, isBlocked: false)
identityManager.setBlocked(fingerprint, isBlocked: false)
return .success(message: "unblocked \(nickname)")
}
// Try geohash unblock
if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) {
if !SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: pub) {
if let pub = contextProvider?.nostrPubkeyForDisplayName(nickname) {
if !identityManager.isNostrBlocked(pubkeyHexLowercased: pub) {
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 .error(message: "cannot unblock \(nickname): not found")
@@ -282,8 +329,8 @@ class CommandProcessor {
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname),
let noisePublicKey = Data(hexString: peerID) else {
guard let peerID = contextProvider?.getPeerIDForNickname(nickname),
let noisePublicKey = Data(hexString: peerID.id) else {
return .error(message: "can't find peer: \(nickname)")
}
@@ -295,33 +342,18 @@ class CommandProcessor {
peerNickname: nickname
)
chatViewModel?.toggleFavorite(peerID: peerID)
chatViewModel?.sendFavoriteNotification(to: peerID, isFavorite: true)
contextProvider?.toggleFavorite(peerID: peerID)
contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: true)
return .success(message: "added \(nickname) to favorites")
} else {
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey)
chatViewModel?.toggleFavorite(peerID: peerID)
chatViewModel?.sendFavoriteNotification(to: peerID, isFavorite: false)
contextProvider?.toggleFavorite(peerID: peerID)
contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: false)
return .success(message: "removed \(nickname) from favorites")
}
}
private func handleHelp() -> CommandResult {
let helpText = """
commands:
/msg @name - start private chat
/who - list who's online
/clear - clear messages
/hug @name - send a hug
/slap @name - slap with a trout
/fav @name - add to favorites
/unfav @name - remove from favorites
/block @name - block
/unblock @name - unblock
"""
return .success(message: helpText)
}
}
@@ -1,9 +1,10 @@
import BitLogger
import Foundation
import Combine
/// Manages persistent favorite relationships between peers
@MainActor
class FavoritesPersistenceService: ObservableObject {
final class FavoritesPersistenceService: ObservableObject {
struct FavoriteRelationship: Codable {
let peerNoisePublicKey: Data
@@ -13,14 +14,19 @@ class FavoritesPersistenceService: ObservableObject {
let theyFavoritedUs: Bool
let favoritedAt: Date
let lastUpdated: Date
// Track what we last sent as OUR npub to this peer, to avoid resending unless it changes
// Note: we do not track which npub we last sent to them; sending happens only on favorite toggle
var isMutual: Bool {
isFavorite && theyFavoritedUs
}
}
// We intentionally do not track when we last sent our npub; sending happens only on favorite toggle.
private static let storageKey = "chat.bitchat.favorites"
private static let keychainService = "chat.bitchat.favorites"
private let keychain: KeychainHelperProtocol
@Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship
@Published private(set) var mutualFavorites: Set<Data> = []
@@ -30,7 +36,8 @@ class FavoritesPersistenceService: ObservableObject {
static let shared = FavoritesPersistenceService()
private init() {
init(keychain: KeychainHelperProtocol = KeychainHelper()) {
self.keychain = keychain
loadFavorites()
// Update mutual favorites when favorites change
@@ -47,8 +54,7 @@ class FavoritesPersistenceService: ObservableObject {
peerNostrPublicKey: String? = nil,
peerNickname: String
) {
SecureLogger.log("⭐️ Adding favorite: \(peerNickname) (\(peerNoisePublicKey.hexEncodedString()))",
category: SecureLogger.session, level: .info)
SecureLogger.info("⭐️ Adding favorite: \(peerNickname) (\(peerNoisePublicKey.hexEncodedString()))", category: .session)
let existing = favorites[peerNoisePublicKey]
@@ -64,8 +70,7 @@ class FavoritesPersistenceService: ObservableObject {
// Log if this creates a mutual favorite
if relationship.isMutual {
SecureLogger.log("💕 Mutual favorite relationship established with \(peerNickname)!",
category: SecureLogger.session, level: .info)
SecureLogger.info("💕 Mutual favorite relationship established with \(peerNickname)!", category: .session)
}
favorites[peerNoisePublicKey] = relationship
@@ -83,8 +88,7 @@ class FavoritesPersistenceService: ObservableObject {
func removeFavorite(peerNoisePublicKey: Data) {
guard let existing = favorites[peerNoisePublicKey] else { return }
SecureLogger.log("⭐️ Removing favorite: \(existing.peerNickname) (\(peerNoisePublicKey.hexEncodedString()))",
category: SecureLogger.session, level: .info)
SecureLogger.info("⭐️ Removing favorite: \(existing.peerNickname) (\(peerNoisePublicKey.hexEncodedString()))", category: .session)
// If they still favorite us, keep the record but mark us as not favoriting
if existing.theyFavoritedUs {
@@ -125,8 +129,7 @@ class FavoritesPersistenceService: ObservableObject {
let existing = favorites[peerNoisePublicKey]
let displayName = peerNickname ?? existing?.peerNickname ?? "Unknown"
SecureLogger.log("📨 Received favorite notification: \(displayName) \(favorited ? "favorited" : "unfavorited") us",
category: SecureLogger.session, level: .info)
SecureLogger.info("📨 Received favorite notification: \(displayName) \(favorited ? "favorited" : "unfavorited") us", category: .session)
let relationship = FavoriteRelationship(
peerNoisePublicKey: peerNoisePublicKey,
@@ -147,8 +150,7 @@ class FavoritesPersistenceService: ObservableObject {
// Check if this creates a mutual favorite
if relationship.isMutual {
SecureLogger.log("💕 Mutual favorite relationship established with \(displayName)!",
category: SecureLogger.session, level: .info)
SecureLogger.info("💕 Mutual favorite relationship established with \(displayName)!", category: .session)
}
}
@@ -179,136 +181,24 @@ class FavoritesPersistenceService: ObservableObject {
/// Resolve favorite status by short peer ID (16-hex derived from Noise pubkey)
/// Falls back to scanning favorites and matching on derived peer ID.
func getFavoriteStatus(forPeerID peerID: String) -> FavoriteRelationship? {
func getFavoriteStatus(forPeerID peerID: PeerID) -> FavoriteRelationship? {
// Quick sanity: peerID should be 16 hex chars (8 bytes)
guard peerID.count == 16 else { return nil }
for (pubkey, rel) in favorites {
let derived = PeerIDUtils.derivePeerID(fromPublicKey: pubkey)
if derived == peerID { return rel }
guard peerID.isShort else { return nil }
for (pubkey, rel) in favorites where PeerID(publicKey: pubkey) == peerID {
return rel
}
return nil
}
/// Update Nostr public key for a peer
func updateNostrPublicKey(for peerNoisePublicKey: Data, nostrPubkey: String) {
guard let existing = favorites[peerNoisePublicKey] else { return }
let updated = FavoriteRelationship(
peerNoisePublicKey: existing.peerNoisePublicKey,
peerNostrPublicKey: nostrPubkey,
peerNickname: existing.peerNickname,
isFavorite: existing.isFavorite,
theyFavoritedUs: existing.theyFavoritedUs,
favoritedAt: existing.favoritedAt,
lastUpdated: Date()
)
favorites[peerNoisePublicKey] = updated
saveFavorites()
}
/// Update nickname for an existing favorite
func updateNickname(for peerNoisePublicKey: Data, newNickname: String) {
guard let existing = favorites[peerNoisePublicKey] else { return }
// Skip if nickname hasn't changed
if existing.peerNickname == newNickname { return }
// Updating nickname for favorite
let updated = FavoriteRelationship(
peerNoisePublicKey: existing.peerNoisePublicKey,
peerNostrPublicKey: existing.peerNostrPublicKey,
peerNickname: newNickname,
isFavorite: existing.isFavorite,
theyFavoritedUs: existing.theyFavoritedUs,
favoritedAt: existing.favoritedAt,
lastUpdated: Date()
)
favorites[peerNoisePublicKey] = updated
saveFavorites()
// Notify observers
NotificationCenter.default.post(
name: .favoriteStatusChanged,
object: nil,
userInfo: ["peerPublicKey": peerNoisePublicKey]
)
}
/// Update noise public key when peer reconnects with new ID
func updateNoisePublicKey(from oldKey: Data, to newKey: Data, peerNickname: String) {
guard let existing = favorites[oldKey] else {
SecureLogger.log("⚠️ Cannot update noise key - no favorite found for \(oldKey.hexEncodedString())",
category: SecureLogger.session, level: .warning)
return
}
// Check if we already have a favorite with the new key
if favorites[newKey] != nil {
SecureLogger.log("⚠️ Favorite already exists with new key \(newKey.hexEncodedString()), removing old entry",
category: SecureLogger.session, level: .warning)
favorites.removeValue(forKey: oldKey)
saveFavorites()
return
}
// Updating noise public key
// Remove old entry
favorites.removeValue(forKey: oldKey)
// Add with new key
let updated = FavoriteRelationship(
peerNoisePublicKey: newKey,
peerNostrPublicKey: existing.peerNostrPublicKey,
peerNickname: peerNickname,
isFavorite: existing.isFavorite,
theyFavoritedUs: existing.theyFavoritedUs,
favoritedAt: existing.favoritedAt,
lastUpdated: Date()
)
favorites[newKey] = updated
saveFavorites()
// Notify observers with both old and new keys
NotificationCenter.default.post(
name: .favoriteStatusChanged,
object: nil,
userInfo: [
"peerPublicKey": newKey,
"oldPeerPublicKey": oldKey,
"isKeyUpdate": true
]
)
}
/// Get all favorites (including non-mutual)
func getAllFavorites() -> [FavoriteRelationship] {
favorites.values.filter { $0.isFavorite }
}
/// Get only mutual favorites
func getMutualFavorites() -> [FavoriteRelationship] {
favorites.values.filter { $0.isMutual }
}
/// Get all favorite relationships (including where they favorited us)
func getAllRelationships() -> [FavoriteRelationship] {
Array(favorites.values)
}
/// Clear all favorites - used for panic mode
func clearAllFavorites() {
SecureLogger.log("🧹 Clearing all favorites (panic mode)", category: SecureLogger.session, level: .warning)
SecureLogger.warning("🧹 Clearing all favorites (panic mode)", category: .session)
favorites.removeAll()
saveFavorites()
// Delete from keychain directly
KeychainHelper.delete(
keychain.delete(
key: Self.storageKey,
service: Self.keychainService
)
@@ -328,22 +218,23 @@ class FavoritesPersistenceService: ObservableObject {
let data = try encoder.encode(relationships)
// Store in keychain for security
KeychainHelper.save(
keychain.save(
key: Self.storageKey,
data: data,
service: Self.keychainService
service: Self.keychainService,
accessible: nil
)
// Successfully saved favorites
} catch {
SecureLogger.log("Failed to save favorites: \(error)", category: SecureLogger.session, level: .error)
SecureLogger.error("Failed to save favorites: \(error)", category: .session)
}
}
private func loadFavorites() {
// Loading favorites from keychain
guard let data = KeychainHelper.load(
guard let data = keychain.load(
key: Self.storageKey,
service: Self.keychainService
) else {
@@ -354,14 +245,12 @@ class FavoritesPersistenceService: ObservableObject {
let decoder = JSONDecoder()
let relationships = try decoder.decode([FavoriteRelationship].self, from: data)
SecureLogger.log("✅ Loaded \(relationships.count) favorite relationships",
category: SecureLogger.session, level: .info)
SecureLogger.info("✅ Loaded \(relationships.count) favorite relationships", category: .session)
// Log Nostr public key info
for relationship in relationships {
if relationship.peerNostrPublicKey == nil {
SecureLogger.log("⚠️ No Nostr public key stored for '\(relationship.peerNickname)'",
category: SecureLogger.session, level: .warning)
SecureLogger.warning("⚠️ No Nostr public key stored for '\(relationship.peerNickname)'", category: .session)
}
}
@@ -372,8 +261,7 @@ class FavoritesPersistenceService: ObservableObject {
for relationship in relationships {
// Check for duplicates by public key (the actual unique identifier)
if let existing = seenPublicKeys[relationship.peerNoisePublicKey] {
SecureLogger.log("⚠️ Duplicate favorite found for public key \(relationship.peerNoisePublicKey.hexEncodedString()) - nicknames: '\(existing.peerNickname)' vs '\(relationship.peerNickname)'",
category: SecureLogger.session, level: .warning)
SecureLogger.warning("⚠️ Duplicate favorite found for public key \(relationship.peerNoisePublicKey.hexEncodedString()) - nicknames: '\(existing.peerNickname)' vs '\(relationship.peerNickname)'", category: .session)
// Keep the most recent or most complete relationship
if relationship.lastUpdated > existing.lastUpdated ||
@@ -414,7 +302,7 @@ class FavoritesPersistenceService: ObservableObject {
// Log loaded relationships
// Loaded relationships successfully
} catch {
SecureLogger.log("Failed to load favorites: \(error)", category: SecureLogger.session, level: .error)
SecureLogger.error("Failed to load favorites: \(error)", category: .session)
}
}
}
@@ -1,227 +0,0 @@
import Foundation
import Combine
#if os(iOS) || os(macOS)
import CoreLocation
#endif
/// Stores a user-maintained list of bookmarked geohash channels.
/// - Persistence: UserDefaults (JSON string array)
/// - Semantics: geohashes are normalized to lowercase base32 and de-duplicated
final class GeohashBookmarksStore: ObservableObject {
static let shared = GeohashBookmarksStore()
@Published private(set) var bookmarks: [String] = []
@Published private(set) var bookmarkNames: [String: String] = [:] // geohash -> friendly name
private let storeKey = "locationChannel.bookmarks"
private let namesStoreKey = "locationChannel.bookmarkNames"
private var membership: Set<String> = []
#if os(iOS) || os(macOS)
private let geocoder = CLGeocoder()
private var resolving: Set<String> = []
#endif
private init() {
load()
}
// MARK: - Public API
func isBookmarked(_ geohash: String) -> Bool {
return membership.contains(Self.normalize(geohash))
}
func toggle(_ geohash: String) {
let gh = Self.normalize(geohash)
if membership.contains(gh) {
remove(gh)
} else {
add(gh)
}
}
func add(_ geohash: String) {
let gh = Self.normalize(geohash)
guard !gh.isEmpty else { return }
guard !membership.contains(gh) else { return }
bookmarks.insert(gh, at: 0)
membership.insert(gh)
persist()
// Resolve and persist a friendly name once when added
resolveNameIfNeeded(for: gh)
}
func remove(_ geohash: String) {
let gh = Self.normalize(geohash)
guard membership.contains(gh) else { return }
if let idx = bookmarks.firstIndex(of: gh) { bookmarks.remove(at: idx) }
membership.remove(gh)
// Clean up stored name to avoid stale cache growth
if bookmarkNames.removeValue(forKey: gh) != nil {
persistNames()
}
persist()
}
// MARK: - Persistence
private func load() {
guard let data = UserDefaults.standard.data(forKey: storeKey) else { return }
if let arr = try? JSONDecoder().decode([String].self, from: data) {
// Sanitize, normalize, dedupe while preserving order (first occurrence wins)
var seen = Set<String>()
var list: [String] = []
for raw in arr {
let gh = Self.normalize(raw)
guard !gh.isEmpty else { continue }
if !seen.contains(gh) {
seen.insert(gh)
list.append(gh)
}
}
bookmarks = list
membership = seen
}
// Load any saved names
if let namesData = UserDefaults.standard.data(forKey: namesStoreKey),
let dict = try? JSONDecoder().decode([String: String].self, from: namesData) {
bookmarkNames = dict
}
}
private func persist() {
if let data = try? JSONEncoder().encode(bookmarks) {
UserDefaults.standard.set(data, forKey: storeKey)
}
}
private func persistNames() {
if let data = try? JSONEncoder().encode(bookmarkNames) {
UserDefaults.standard.set(data, forKey: namesStoreKey)
}
}
// MARK: - Helpers
private static func normalize(_ s: String) -> String {
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
return s
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
.replacingOccurrences(of: "#", with: "")
.filter { allowed.contains($0) }
}
// MARK: - Name Resolution
/// Attempt to resolve and persist a friendly place name for a bookmarked geohash.
func resolveNameIfNeeded(for geohash: String) {
let gh = Self.normalize(geohash)
guard !gh.isEmpty else { return }
if bookmarkNames[gh] != nil { return }
#if os(iOS) || os(macOS)
if resolving.contains(gh) { return }
resolving.insert(gh)
// For very coarse geohashes, sample multiple points to capture multiple admin areas
if gh.count <= 2 {
let b = Geohash.decodeBounds(gh)
let pts: [CLLocation] = [
CLLocation(latitude: (b.latMin + b.latMax) / 2, longitude: (b.lonMin + b.lonMax) / 2), // center
CLLocation(latitude: b.latMin, longitude: b.lonMin),
CLLocation(latitude: b.latMin, longitude: b.lonMax),
CLLocation(latitude: b.latMax, longitude: b.lonMin),
CLLocation(latitude: b.latMax, longitude: b.lonMax)
]
resolveCompositeAdminName(geohash: gh, points: pts)
} else {
let center = Geohash.decodeCenter(gh)
let loc = CLLocation(latitude: center.lat, longitude: center.lon)
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
guard let self = self else { return }
defer { self.resolving.remove(gh) }
if let pm = placemarks?.first {
let name = Self.nameForGeohashLength(gh.count, from: pm)
if let name = name, !name.isEmpty {
DispatchQueue.main.async {
self.bookmarkNames[gh] = name
self.persistNames()
}
}
}
}
}
#endif
}
#if os(iOS) || os(macOS)
private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) {
var uniqueAdmins = OrderedSet<String>()
var idx = 0
func step() {
if idx >= points.count {
// Compose up to 2 names joined by ' and '
let finalName: String? = {
let names = uniqueAdmins.array
if names.count >= 2 { return names[0] + " and " + names[1] }
return names.first
}()
if let finalName = finalName, !finalName.isEmpty {
DispatchQueue.main.async {
self.bookmarkNames[gh] = finalName
self.persistNames()
}
}
self.resolving.remove(gh)
return
}
let loc = points[idx]
idx += 1
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
guard self != nil else { return }
if let pm = placemarks?.first {
if let admin = pm.administrativeArea, !admin.isEmpty {
uniqueAdmins.insert(admin)
} else if let country = pm.country, !country.isEmpty {
uniqueAdmins.insert(country)
}
}
// Proceed to next point
step()
}
}
step()
}
// Minimal ordered-set for stable joining
private struct OrderedSet<Element: Hashable> {
private var set: Set<Element> = []
private(set) var array: [Element] = []
mutating func insert(_ element: Element) {
if set.insert(element).inserted { array.append(element) }
}
}
private static func nameForGeohashLength(_ len: Int, from pm: CLPlacemark) -> String? {
switch len {
case 0...2:
// Prefer administrative area if available at this coarse level
return pm.administrativeArea ?? pm.country
case 3...4:
return pm.administrativeArea ?? pm.subAdministrativeArea ?? pm.country
case 5:
return pm.locality ?? pm.subAdministrativeArea ?? pm.administrativeArea
case 6...7:
return pm.subLocality ?? pm.locality ?? pm.administrativeArea
default:
return pm.subLocality ?? pm.locality ?? pm.administrativeArea ?? pm.country
}
}
#endif
#if DEBUG
/// Testing-only reset helper
func _resetForTesting() {
bookmarks.removeAll()
membership.removeAll()
bookmarkNames.removeAll()
persist()
persistNames()
}
#endif
}
@@ -0,0 +1,159 @@
//
// GeohashParticipantTracker.swift
// bitchat
//
// Tracks participants in geohash-based location channels.
// This is free and unencumbered software released into the public domain.
//
import Foundation
/// Represents a participant in a geohash channel
public struct GeoPerson: Identifiable, Equatable, Sendable {
public let id: String // pubkey hex (lowercased)
public let displayName: String
public let lastSeen: Date
public init(id: String, displayName: String, lastSeen: Date) {
self.id = id
self.displayName = displayName
self.lastSeen = lastSeen
}
}
/// Protocol for resolving display names and checking block status
@MainActor
public protocol GeohashParticipantContext: AnyObject {
/// Returns display name for a Nostr pubkey (e.g., "alice#a1b2" or "anon#c3d4")
func displayNameForPubkey(_ pubkeyHex: String) -> String
/// Returns true if the pubkey is blocked
func isBlocked(_ pubkeyHexLowercased: String) -> Bool
}
/// Tracks participants across multiple geohash channels
@MainActor
public final class GeohashParticipantTracker: ObservableObject {
/// Activity cutoff duration (defaults to 5 minutes)
public let activityCutoff: TimeInterval
/// Per-geohash participant map: [geohash: [pubkeyHex: lastSeen]]
private var participants: [String: [String: Date]] = [:]
/// Currently visible people for the active geohash
@Published public private(set) var visiblePeople: [GeoPerson] = []
/// The currently active geohash (if any)
private var activeGeohash: String?
/// Context for display name resolution and block checking
private weak var context: GeohashParticipantContext?
/// Timer for periodic refresh
private var refreshTimer: Timer?
public init(activityCutoff: TimeInterval = -300) { // default 5 minutes
self.activityCutoff = activityCutoff
}
/// Configure with a context provider
public func configure(context: GeohashParticipantContext) {
self.context = context
}
/// Set the currently active geohash
public func setActiveGeohash(_ geohash: String?) {
activeGeohash = geohash
if geohash == nil {
visiblePeople = []
} else {
refresh()
}
}
/// Record activity from a participant in the current active geohash
public func recordParticipant(pubkeyHex: String) {
guard let gh = activeGeohash else { return }
recordParticipant(pubkeyHex: pubkeyHex, geohash: gh)
}
/// Record activity from a participant in a specific geohash
public func recordParticipant(pubkeyHex: String, geohash: String) {
let key = pubkeyHex.lowercased()
var map = participants[geohash] ?? [:]
map[key] = Date()
participants[geohash] = map
// Only refresh visible list if this geohash is currently active
if activeGeohash == geohash {
refresh()
}
}
/// Remove a participant from all geohashes (used when blocking)
public func removeParticipant(pubkeyHex: String) {
let key = pubkeyHex.lowercased()
for (gh, var map) in participants {
map.removeValue(forKey: key)
participants[gh] = map
}
refresh()
}
/// Get participant count for a specific geohash
public func participantCount(for geohash: String) -> Int {
let cutoff = Date().addingTimeInterval(activityCutoff)
let map = participants[geohash] ?? [:]
return map.values.filter { $0 >= cutoff }.count
}
/// Get the visible people list for the active geohash (read-only query)
public func getVisiblePeople() -> [GeoPerson] {
guard let gh = activeGeohash, let context = context else { return [] }
let cutoff = Date().addingTimeInterval(activityCutoff)
let map = (participants[gh] ?? [:])
.filter { $0.value >= cutoff }
.filter { !context.isBlocked($0.key) }
return map
.map { (pub, seen) in
GeoPerson(id: pub, displayName: context.displayNameForPubkey(pub), lastSeen: seen)
}
.sorted { $0.lastSeen > $1.lastSeen }
}
/// Refresh the visible people list
public func refresh() {
visiblePeople = getVisiblePeople()
}
/// Start the periodic refresh timer
public func startRefreshTimer(interval: TimeInterval = 30.0) {
stopRefreshTimer()
refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
Task { @MainActor in
self?.refresh()
}
}
}
/// Stop the periodic refresh timer
public func stopRefreshTimer() {
refreshTimer?.invalidate()
refreshTimer = nil
}
/// Clear all participant data
public func clear() {
participants.removeAll()
visiblePeople = []
}
/// Clear participant data for a specific geohash
public func clear(geohash: String) {
participants.removeValue(forKey: geohash)
if activeGeohash == geohash {
visiblePeople = []
}
}
}
+26 -46
View File
@@ -6,54 +6,33 @@
// For more information, see <https://unlicense.org>
//
import BitLogger
import Foundation
import Security
import os.log
class KeychainManager {
static let shared = KeychainManager()
protocol KeychainManagerProtocol {
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
private let service = "chat.bitchat"
private let appGroup = "group.chat.bitchat"
private init() {}
private func isSandboxed() -> Bool {
#if os(macOS)
// More robust sandbox detection using multiple methods
// Method 1: Check environment variable (can be spoofed)
let environment = ProcessInfo.processInfo.environment
let hasEnvVar = environment["APP_SANDBOX_CONTAINER_ID"] != nil
// Method 2: Check if we can access a path outside sandbox
let homeDir = FileManager.default.homeDirectoryForCurrentUser
let testPath = homeDir.appendingPathComponent("../../../tmp/bitchat_sandbox_test_\(UUID().uuidString)")
let canWriteOutsideSandbox = FileManager.default.createFile(atPath: testPath.path, contents: nil, attributes: nil)
if canWriteOutsideSandbox {
try? FileManager.default.removeItem(at: testPath)
}
// Method 3: Check container path
let containerPath = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first?.path ?? ""
let hasContainerPath = containerPath.contains("/Containers/")
// If any method indicates sandbox, we consider it sandboxed
return hasEnvVar || !canWriteOutsideSandbox || hasContainerPath
#else
// iOS is always sandboxed
return true
#endif
}
private let service = BitchatApp.bundleID
private let appGroup = "group.\(BitchatApp.bundleID)"
// MARK: - Identity Keys
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
let fullKey = "identity_\(key)"
let result = saveData(keyData, forKey: fullKey)
SecureLogger.logKeyOperation("save", keyType: key, success: result)
SecureLogger.logKeyOperation(.save, keyType: key, success: result)
return result
}
@@ -64,7 +43,7 @@ class KeychainManager {
func deleteIdentityKey(forKey key: String) -> Bool {
let result = delete(forKey: "identity_\(key)")
SecureLogger.logKeyOperation("delete", keyType: key, success: result)
SecureLogger.logKeyOperation(.delete, keyType: key, success: result)
return result
}
@@ -113,9 +92,9 @@ class KeychainManager {
if status == errSecSuccess { return true }
if status == -34018 && !triedWithoutGroup {
SecureLogger.logError(NSError(domain: "Keychain", code: -34018), context: "Missing keychain entitlement", category: SecureLogger.keychain)
SecureLogger.error(NSError(domain: "Keychain", code: -34018), context: "Missing keychain entitlement", category: .keychain)
} else if status != errSecDuplicateItem {
SecureLogger.logError(NSError(domain: "Keychain", code: Int(status)), context: "Error saving to keychain", category: SecureLogger.keychain)
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)), context: "Error saving to keychain", category: .keychain)
}
return false
}
@@ -151,7 +130,7 @@ class KeychainManager {
if status == errSecSuccess { return result as? Data }
if status == -34018 {
SecureLogger.logError(NSError(domain: "Keychain", code: -34018), context: "Missing keychain entitlement", category: SecureLogger.keychain)
SecureLogger.error(NSError(domain: "Keychain", code: -34018), context: "Missing keychain entitlement", category: .keychain)
}
return nil
}
@@ -198,7 +177,7 @@ class KeychainManager {
// Delete ALL keychain data for panic mode
func deleteAllKeychainData() -> Bool {
SecureLogger.log("Panic mode - deleting all keychain data", category: SecureLogger.security, level: .warning)
SecureLogger.warning("Panic mode - deleting all keychain data", category: .security)
var totalDeleted = 0
@@ -261,7 +240,7 @@ class KeychainManager {
let deleteStatus = SecItemDelete(deleteQuery as CFDictionary)
if deleteStatus == errSecSuccess {
totalDeleted += 1
SecureLogger.log("Deleted keychain item: \(account) from \(service)", category: SecureLogger.keychain, level: .info)
SecureLogger.info("Deleted keychain item: \(account) from \(service)", category: .keychain)
}
}
}
@@ -275,6 +254,7 @@ class KeychainManager {
"com.bitchat.deviceidentity",
"com.bitchat.noise.identity",
"chat.bitchat.passwords",
"chat.bitchat.nostr",
"bitchat.keychain",
"bitchat",
"com.bitchat"
@@ -303,7 +283,7 @@ class KeychainManager {
totalDeleted += 1
}
SecureLogger.log("Panic mode cleanup completed. Total items deleted: \(totalDeleted)", category: SecureLogger.keychain, level: .warning)
SecureLogger.warning("Panic mode cleanup completed. Total items deleted: \(totalDeleted)", category: .keychain)
return totalDeleted > 0
}
@@ -311,7 +291,7 @@ class KeychainManager {
// MARK: - Security Utilities
/// Securely clear sensitive data from memory
static func secureClear(_ data: inout Data) {
func secureClear(_ data: inout Data) {
_ = data.withUnsafeMutableBytes { bytes in
// Use volatile memset to prevent compiler optimization
memset_s(bytes.baseAddress, bytes.count, 0, bytes.count)
@@ -320,7 +300,7 @@ class KeychainManager {
}
/// Securely clear sensitive string from memory
static func secureClear(_ string: inout String) {
func secureClear(_ string: inout String) {
// Convert to mutable data and clear
if var data = string.data(using: .utf8) {
secureClear(&data)
@@ -1,298 +0,0 @@
import Foundation
import Combine
#if os(iOS) || os(macOS)
import CoreLocation
/// Manages location permissions, one-shot location retrieval, and computing geohash channels.
/// Not main-actor isolated to satisfy CLLocationManagerDelegate in Swift 6; state updates hop to MainActor.
final class LocationChannelManager: NSObject, CLLocationManagerDelegate, ObservableObject {
static let shared = LocationChannelManager()
enum PermissionState: Equatable {
case notDetermined
case denied
case restricted
case authorized
}
private let cl = CLLocationManager()
private let geocoder = CLGeocoder()
private var lastLocation: CLLocation?
private var refreshTimer: Timer?
private let userDefaultsKey = "locationChannel.selected"
private let teleportedStoreKey = "locationChannel.teleportedSet"
private var isGeocoding: Bool = false
// Published state for UI bindings
@Published private(set) var permissionState: PermissionState = .notDetermined
@Published private(set) var availableChannels: [GeohashChannel] = []
@Published private(set) var selectedChannel: ChannelID = .mesh
// True when the current location channel was selected via manual teleport
@Published var teleported: Bool = false
@Published private(set) var locationNames: [GeohashChannelLevel: String] = [:]
// Persisted set of geohashes that were selected via teleport
private var teleportedSet: Set<String> = []
private override init() {
super.init()
cl.delegate = self
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters // meters; we're not tracking continuously
// Load selection
if let data = UserDefaults.standard.data(forKey: userDefaultsKey),
let channel = try? JSONDecoder().decode(ChannelID.self, from: data) {
selectedChannel = channel
}
// Load persisted teleported set
if let data = UserDefaults.standard.data(forKey: teleportedStoreKey),
let arr = try? JSONDecoder().decode([String].self, from: data) {
teleportedSet = Set(arr)
}
// Do not eagerly mark teleported on startup; wait for location to compute regional set.
// This avoids showing teleported for in-region channels during cold start.
let status: CLAuthorizationStatus
if #available(iOS 14.0, macOS 11.0, *) {
status = cl.authorizationStatus
} else {
status = CLLocationManager.authorizationStatus()
}
updatePermissionState(from: status)
// If we don't have location authorization at startup, fall back to persisted teleport state
switch status {
case .authorizedAlways, .authorizedWhenInUse, .authorized:
break // will compute from location
default:
if case .location(let ch) = selectedChannel {
teleported = teleportedSet.contains(ch.geohash)
}
}
}
// MARK: - Public API
func enableLocationChannels() {
let status: CLAuthorizationStatus
if #available(iOS 14.0, macOS 11.0, *) {
status = cl.authorizationStatus
} else {
status = CLLocationManager.authorizationStatus()
}
switch status {
case .notDetermined:
cl.requestWhenInUseAuthorization()
case .restricted:
Task { @MainActor in self.permissionState = .restricted }
case .denied:
Task { @MainActor in self.permissionState = .denied }
case .authorizedAlways, .authorizedWhenInUse, .authorized:
Task { @MainActor in self.permissionState = .authorized }
requestOneShotLocation()
@unknown default:
Task { @MainActor in self.permissionState = .restricted }
}
}
func refreshChannels() {
if permissionState == .authorized {
requestOneShotLocation()
}
}
/// Begin continuous, distance-filtered updates while the channel sheet is visible.
/// Uses a 21m filter (configurable) to only refresh on meaningful movement.
func beginLiveRefresh(interval: TimeInterval = TransportConfig.locationLiveRefreshInterval) {
guard permissionState == .authorized else { return }
// Stop any previous polling timer
refreshTimer?.invalidate()
refreshTimer = nil
// Tighten accuracy and distance filter for live view
cl.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
cl.distanceFilter = TransportConfig.locationDistanceFilterLiveMeters
// Start continuous updates
cl.startUpdatingLocation()
// Request an immediate fix to populate UI without waiting for movement
requestOneShotLocation()
}
/// Stop continuous refreshes when selector UI is dismissed.
func endLiveRefresh() {
refreshTimer?.invalidate()
refreshTimer = nil
cl.stopUpdatingLocation()
// Restore more relaxed defaults for background/idle state
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters
}
func select(_ channel: ChannelID) {
Task { @MainActor in
self.selectedChannel = channel
if let data = try? JSONEncoder().encode(channel) {
UserDefaults.standard.set(data, forKey: self.userDefaultsKey)
}
// Update teleported flag based on persisted state for immediate UI behavior
switch channel {
case .mesh:
self.teleported = false
case .location(let ch):
// If this geohash is in our current regional set, do NOT mark teleported.
let inRegional = self.availableChannels.contains { $0.geohash == ch.geohash }
if inRegional {
self.teleported = false
// Clear persisted teleport for this geohash to keep future selections clean
if self.teleportedSet.contains(ch.geohash) {
self.teleportedSet.remove(ch.geohash)
if let data = try? JSONEncoder().encode(Array(self.teleportedSet)) {
UserDefaults.standard.set(data, forKey: self.teleportedStoreKey)
}
}
} else {
// Fall back to persisted mark (set by deep link or manual teleport)
self.teleported = self.teleportedSet.contains(ch.geohash)
}
}
}
}
// Mark or unmark a geohash as teleported in persistence and update current flag if relevant
func markTeleported(for geohash: String, _ flag: Bool) {
if flag { teleportedSet.insert(geohash) } else { teleportedSet.remove(geohash) }
if let data = try? JSONEncoder().encode(Array(teleportedSet)) {
UserDefaults.standard.set(data, forKey: teleportedStoreKey)
}
if case .location(let ch) = selectedChannel, ch.geohash == geohash {
Task { @MainActor in self.teleported = flag }
}
}
// MARK: - CoreLocation
private func requestOneShotLocation() {
cl.requestLocation()
}
// iOS < 14
func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
updatePermissionState(from: status)
if case .authorized = permissionState {
requestOneShotLocation()
}
}
// iOS 14+ / macOS 11+
@available(iOS 14.0, macOS 11.0, *)
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
updatePermissionState(from: manager.authorizationStatus)
if case .authorized = permissionState {
requestOneShotLocation()
}
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let loc = locations.last else { return }
lastLocation = loc
computeChannels(from: loc.coordinate)
reverseGeocodeIfNeeded(location: loc)
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
// Surface as denied/restricted if relevant; otherwise keep previous state
SecureLogger.log("LocationChannelManager: location error: \(error.localizedDescription)",
category: SecureLogger.session, level: .error)
}
// MARK: - Helpers
private func updatePermissionState(from status: CLAuthorizationStatus) {
let newState: PermissionState
switch status {
case .notDetermined: newState = .notDetermined
case .restricted: newState = .restricted
case .denied: newState = .denied
case .authorizedAlways, .authorizedWhenInUse, .authorized: newState = .authorized
@unknown default: newState = .restricted
}
Task { @MainActor in self.permissionState = newState }
}
private func computeChannels(from coord: CLLocationCoordinate2D) {
let levels = GeohashChannelLevel.allCases
var result: [GeohashChannel] = []
for level in levels {
let gh = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: level.precision)
result.append(GeohashChannel(level: level, geohash: gh))
}
Task { @MainActor in
self.availableChannels = result
// Recompute teleported status based on whether the selected geohash is in our regional set
switch self.selectedChannel {
case .mesh:
self.teleported = false
case .location(let ch):
// Membership check using freshly computed regional channels; avoids precision/rename drift
let inRegional = result.contains { $0.geohash == ch.geohash }
if inRegional {
self.teleported = false
// Clear persisted teleport flag if present
if self.teleportedSet.contains(ch.geohash) {
self.teleportedSet.remove(ch.geohash)
if let data = try? JSONEncoder().encode(Array(self.teleportedSet)) {
UserDefaults.standard.set(data, forKey: self.teleportedStoreKey)
}
}
} else {
self.teleported = true
}
}
}
}
private func reverseGeocodeIfNeeded(location: CLLocation) {
// Always cancel previous to keep latest fresh while user moves
geocoder.cancelGeocode()
isGeocoding = true
geocoder.reverseGeocodeLocation(location) { [weak self] placemarks, error in
guard let self = self else { return }
self.isGeocoding = false
if let pm = placemarks?.first {
let names = self.namesByLevel(from: pm)
Task { @MainActor in self.locationNames = names }
}
}
}
private func namesByLevel(from pm: CLPlacemark) -> [GeohashChannelLevel: String] {
var dict: [GeohashChannelLevel: String] = [:]
// Region (country)
if let country = pm.country, !country.isEmpty {
dict[.region] = country
}
// Province (state/province or county)
if let admin = pm.administrativeArea, !admin.isEmpty {
dict[.province] = admin
} else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {
dict[.province] = subAdmin
}
// City (locality)
if let locality = pm.locality, !locality.isEmpty {
dict[.city] = locality
} else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {
dict[.city] = subAdmin
} else if let admin = pm.administrativeArea, !admin.isEmpty {
dict[.city] = admin
}
// Neighborhood
if let subLocality = pm.subLocality, !subLocality.isEmpty {
dict[.neighborhood] = subLocality
} else if let locality = pm.locality, !locality.isEmpty {
dict[.neighborhood] = locality
}
// Block: reuse neighborhood/locality granularity without exposing street level
if let subLocality = pm.subLocality, !subLocality.isEmpty {
dict[.block] = subLocality
} else if let locality = pm.locality, !locality.isEmpty {
dict[.block] = locality
}
return dict
}
}
#endif
+257
View File
@@ -0,0 +1,257 @@
import BitLogger
import Foundation
/// Dependencies for location notes, allowing tests to stub relay/identity behavior.
struct LocationNotesDependencies {
typealias RelayLookup = @MainActor (_ geohash: String, _ count: Int) -> [String]
typealias Subscribe = @MainActor (_ filter: NostrFilter, _ id: String, _ relays: [String], _ handler: @escaping (NostrEvent) -> Void, _ onEOSE: (() -> Void)?) -> Void
typealias Unsubscribe = @MainActor (_ id: String) -> Void
typealias SendEvent = @MainActor (_ event: NostrEvent, _ relayUrls: [String]) -> Void
var relayLookup: RelayLookup
var subscribe: Subscribe
var unsubscribe: Unsubscribe
var sendEvent: SendEvent
var deriveIdentity: (_ geohash: String) throws -> NostrIdentity
var now: () -> Date
private static let idBridge = NostrIdentityBridge()
static let live = LocationNotesDependencies(
relayLookup: { geohash, count in
GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count)
},
subscribe: { filter, id, relays, handler, onEOSE in
NostrRelayManager.shared.subscribe(
filter: filter,
id: id,
relayUrls: relays,
handler: handler,
onEOSE: onEOSE
)
},
unsubscribe: { id in
NostrRelayManager.shared.unsubscribe(id: id)
},
sendEvent: { event, relays in
NostrRelayManager.shared.sendEvent(event, to: relays)
},
deriveIdentity: { geohash in
try idBridge.deriveIdentity(forGeohash: geohash)
},
now: { Date() }
)
}
/// Persistent location notes (Nostr kind 1) scoped to a building-level geohash (precision 8).
/// Subscribes to and publishes notes for a given geohash and provides a send API.
@MainActor
final class LocationNotesManager: ObservableObject {
enum State: Equatable {
case idle
case loading
case ready
case noRelays
}
struct Note: Identifiable, Equatable {
let id: String
let pubkey: String
let content: String
let createdAt: Date
let nickname: String?
var displayName: String {
let suffix = String(pubkey.suffix(4))
if let nick = nickname, !nick.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return "\(nick)#\(suffix)"
}
return "anon#\(suffix)"
}
}
@Published private(set) var notes: [Note] = [] // reverse-chron sorted
@Published private(set) var geohash: String
@Published private(set) var initialLoadComplete: Bool = false
@Published private(set) var state: State = .loading
@Published private(set) var errorMessage: String?
private var subscriptionID: String?
private var noteIDs = Set<String>() // O(1) duplicate detection
private let dependencies: LocationNotesDependencies
private let maxNotesInMemory = 500 // Defensive cap (relay limit is 200)
private enum Strings {
static let noRelays = String(localized: "location_notes.error.no_relays", comment: "Shown when no geo relays are available near the selected location")
static func failedToSend(_ detail: String) -> String {
String(
format: String(localized: "location_notes.error.failed_to_send", comment: "Shown when a location note fails to send"),
locale: .current,
detail
)
}
}
init(geohash: String, dependencies: LocationNotesDependencies = .live) {
let norm = geohash.lowercased()
self.geohash = norm
self.dependencies = dependencies
// Validate geohash (building-level precision: 8 chars)
if !Geohash.isValidBuildingGeohash(norm) {
SecureLogger.warning("LocationNotesManager: invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
}
subscribe()
}
func setGeohash(_ newGeohash: String) {
let norm = newGeohash.lowercased()
guard norm != geohash else { return }
// Validate geohash (building-level precision: 8 chars)
guard Geohash.isValidBuildingGeohash(norm) else {
SecureLogger.warning("LocationNotesManager: rejecting invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
return
}
if let sub = subscriptionID {
dependencies.unsubscribe(sub)
subscriptionID = nil
}
// Set loading state before clearing to prevent empty state flicker
state = .loading
initialLoadComplete = false
errorMessage = nil
geohash = norm
notes.removeAll()
noteIDs.removeAll()
subscribe()
}
func refresh() {
if let sub = subscriptionID {
dependencies.unsubscribe(sub)
subscriptionID = nil
}
// Set loading state before clearing to prevent empty state flicker
state = .loading
initialLoadComplete = false
errorMessage = nil
notes.removeAll()
noteIDs.removeAll()
subscribe()
}
func clearError() {
errorMessage = nil
}
private func subscribe() {
state = .loading
errorMessage = nil
if let sub = subscriptionID {
dependencies.unsubscribe(sub)
subscriptionID = nil
}
let subID = "locnotes-\(geohash)-\(UUID().uuidString.prefix(8))"
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
guard !relays.isEmpty else {
subscriptionID = nil
initialLoadComplete = true
state = .noRelays
errorMessage = Strings.noRelays
SecureLogger.warning("LocationNotesManager: no geo relays for geohash=\(geohash)", category: .session)
return
}
subscriptionID = subID
initialLoadComplete = false
// Subscribe to center + 8 neighbors (± 1 grid)
let neighbors = Geohash.neighbors(of: geohash)
let allGeohashes = [geohash] + neighbors
let filter = NostrFilter.geohashNotes(allGeohashes, since: nil, limit: 200)
// Build a set of valid geohashes for tag matching (includes all 9 cells)
let validGeohashes = Set(allGeohashes.map { $0.lowercased() })
dependencies.subscribe(filter, subID, relays, { [weak self] event in
guard let self = self else { return }
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
// Ensure matching tag - accept any of our 9 geohashes
guard event.tags.contains(where: { tag in
tag.count >= 2 && tag[0].lowercased() == "g" && validGeohashes.contains(tag[1].lowercased())
}) else { return }
guard !self.noteIDs.contains(event.id) else { return }
self.noteIDs.insert(event.id)
let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first
let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick)
self.notes.append(note)
self.notes.sort { $0.createdAt > $1.createdAt }
self.enforceMemoryCap()
self.state = .ready
}, { [weak self] in
guard let self = self else { return }
self.initialLoadComplete = true
if self.state != .noRelays {
self.state = .ready
}
})
}
/// Send a location note for the current geohash using the per-geohash identity.
func send(content: String, nickname: String) {
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
guard !relays.isEmpty else {
state = .noRelays
errorMessage = Strings.noRelays
SecureLogger.warning("LocationNotesManager: send blocked, no geo relays for geohash=\(geohash)", category: .session)
return
}
do {
let id = try dependencies.deriveIdentity(geohash)
let event = try NostrProtocol.createGeohashTextNote(
content: trimmed,
geohash: geohash,
senderIdentity: id,
nickname: nickname
)
dependencies.sendEvent(event, relays)
// Optimistic local-echo
let echo = Note(
id: event.id,
pubkey: id.publicKeyHex,
content: trimmed,
createdAt: Date(timeIntervalSince1970: TimeInterval(event.created_at)),
nickname: nickname
)
self.noteIDs.insert(event.id)
self.notes.insert(echo, at: 0)
self.enforceMemoryCap()
self.state = .ready
self.errorMessage = nil
} catch {
SecureLogger.error("LocationNotesManager: failed to send note: \(error)", category: .session)
errorMessage = Strings.failedToSend(error.localizedDescription)
}
}
/// Enforces defensive memory cap on notes array (keeps newest).
private func enforceMemoryCap() {
if notes.count > maxNotesInMemory {
let removed = notes.count - maxNotesInMemory
notes = Array(notes.prefix(maxNotesInMemory))
SecureLogger.debug("LocationNotesManager: trimmed \(removed) old notes (cap: \(maxNotesInMemory))", category: .session)
}
}
/// Explicitly cancel subscription and release resources.
func cancel() {
if let sub = subscriptionID {
dependencies.unsubscribe(sub)
subscriptionID = nil
}
state = .idle
errorMessage = nil
}
}
+545
View File
@@ -0,0 +1,545 @@
import BitLogger
import Foundation
import Combine
#if os(iOS) || os(macOS)
import CoreLocation
/// Unified manager for location-based channel state including:
/// - CoreLocation permissions and one-shot location retrieval
/// - Geohash channel computation from coordinates
/// - Channel selection and teleport state
/// - Bookmark persistence and friendly name resolution
///
/// Consolidates LocationChannelManager + GeohashBookmarksStore into a single source of truth.
final class LocationStateManager: NSObject, CLLocationManagerDelegate, ObservableObject {
static let shared = LocationStateManager()
// MARK: - Permission State
enum PermissionState: Equatable {
case notDetermined
case denied
case restricted
case authorized
}
// MARK: - Private Properties (CoreLocation)
private let cl = CLLocationManager()
private let geocoder = CLGeocoder()
private var lastLocation: CLLocation?
private var refreshTimer: Timer?
private var isGeocoding: Bool = false
// MARK: - Persistence Keys
private let selectedChannelKey = "locationChannel.selected"
private let teleportedStoreKey = "locationChannel.teleportedSet"
private let bookmarksKey = "locationChannel.bookmarks"
private let bookmarkNamesKey = "locationChannel.bookmarkNames"
// MARK: - Published State (Channel)
@Published private(set) var permissionState: PermissionState = .notDetermined
@Published private(set) var availableChannels: [GeohashChannel] = []
@Published private(set) var selectedChannel: ChannelID = .mesh
@Published var teleported: Bool = false
@Published private(set) var locationNames: [GeohashChannelLevel: String] = [:]
// MARK: - Published State (Bookmarks)
@Published private(set) var bookmarks: [String] = []
@Published private(set) var bookmarkNames: [String: String] = [:]
// MARK: - Private State
private var teleportedSet: Set<String> = []
private var bookmarkMembership: Set<String> = []
private var resolvingNames: Set<String> = []
private let storage: UserDefaults
/// Returns true if running in test environment
private static var isRunningTests: Bool {
let env = ProcessInfo.processInfo.environment
return NSClassFromString("XCTestCase") != nil ||
env["XCTestConfigurationFilePath"] != nil ||
env["XCTestBundlePath"] != nil ||
env["GITHUB_ACTIONS"] != nil ||
env["CI"] != nil
}
// MARK: - Initialization
private override init() {
self.storage = .standard
super.init()
// Skip CoreLocation setup in test environments
guard !Self.isRunningTests else {
loadPersistedState()
return
}
cl.delegate = self
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters
loadPersistedState()
initializePermissionState()
}
/// Internal initializer for testing with custom storage
init(storage: UserDefaults) {
self.storage = storage
super.init()
loadPersistedState()
}
private func loadPersistedState() {
// Load selected channel
if let data = storage.data(forKey: selectedChannelKey),
let channel = try? JSONDecoder().decode(ChannelID.self, from: data) {
selectedChannel = channel
}
// Load teleported set
if let data = storage.data(forKey: teleportedStoreKey),
let arr = try? JSONDecoder().decode([String].self, from: data) {
teleportedSet = Set(arr)
}
// Load bookmarks
if let data = storage.data(forKey: bookmarksKey),
let arr = try? JSONDecoder().decode([String].self, from: data) {
var seen = Set<String>()
var list: [String] = []
for raw in arr {
let gh = Self.normalizeGeohash(raw)
guard !gh.isEmpty, !seen.contains(gh) else { continue }
seen.insert(gh)
list.append(gh)
}
bookmarks = list
bookmarkMembership = seen
}
// Load bookmark names
if let data = storage.data(forKey: bookmarkNamesKey),
let dict = try? JSONDecoder().decode([String: String].self, from: data) {
bookmarkNames = dict
}
}
private func initializePermissionState() {
let status: CLAuthorizationStatus
if #available(iOS 14.0, macOS 11.0, *) {
status = cl.authorizationStatus
} else {
status = CLLocationManager.authorizationStatus()
}
updatePermissionState(from: status)
// Fall back to persisted teleport state if no location authorization
switch status {
case .authorizedAlways, .authorizedWhenInUse, .authorized:
break
case .notDetermined, .restricted, .denied:
fallthrough
@unknown default:
if case .location(let ch) = selectedChannel {
teleported = teleportedSet.contains(ch.geohash)
}
}
}
// MARK: - Public API (Permissions & Location)
func enableLocationChannels() {
let status: CLAuthorizationStatus
if #available(iOS 14.0, macOS 11.0, *) {
status = cl.authorizationStatus
} else {
status = CLLocationManager.authorizationStatus()
}
switch status {
case .notDetermined:
cl.requestWhenInUseAuthorization()
case .restricted:
Task { @MainActor in self.permissionState = .restricted }
case .denied:
Task { @MainActor in self.permissionState = .denied }
case .authorizedAlways, .authorizedWhenInUse, .authorized:
Task { @MainActor in self.permissionState = .authorized }
requestOneShotLocation()
@unknown default:
Task { @MainActor in self.permissionState = .restricted }
}
}
func refreshChannels() {
if permissionState == .authorized {
requestOneShotLocation()
}
}
func beginLiveRefresh(interval: TimeInterval = TransportConfig.locationLiveRefreshInterval) {
guard permissionState == .authorized else { return }
refreshTimer?.invalidate()
refreshTimer = nil
cl.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
cl.distanceFilter = TransportConfig.locationDistanceFilterLiveMeters
cl.startUpdatingLocation()
requestOneShotLocation()
}
func endLiveRefresh() {
refreshTimer?.invalidate()
refreshTimer = nil
cl.stopUpdatingLocation()
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters
}
// MARK: - Public API (Channel Selection)
func select(_ channel: ChannelID) {
Task { @MainActor in
self.selectedChannel = channel
if let data = try? JSONEncoder().encode(channel) {
self.storage.set(data, forKey: self.selectedChannelKey)
}
switch channel {
case .mesh:
self.teleported = false
case .location(let ch):
let inRegional = self.availableChannels.contains { $0.geohash == ch.geohash }
if inRegional {
self.teleported = false
if self.teleportedSet.contains(ch.geohash) {
self.teleportedSet.remove(ch.geohash)
self.persistTeleportedSet()
}
} else {
self.teleported = self.teleportedSet.contains(ch.geohash)
}
}
}
}
func markTeleported(for geohash: String, _ flag: Bool) {
if flag {
teleportedSet.insert(geohash)
} else {
teleportedSet.remove(geohash)
}
persistTeleportedSet()
if case .location(let ch) = selectedChannel, ch.geohash == geohash {
Task { @MainActor in self.teleported = flag }
}
}
// MARK: - Public API (Bookmarks)
func isBookmarked(_ geohash: String) -> Bool {
bookmarkMembership.contains(Self.normalizeGeohash(geohash))
}
func toggleBookmark(_ geohash: String) {
let gh = Self.normalizeGeohash(geohash)
if bookmarkMembership.contains(gh) {
removeBookmark(gh)
} else {
addBookmark(gh)
}
}
func addBookmark(_ geohash: String) {
let gh = Self.normalizeGeohash(geohash)
guard !gh.isEmpty, !bookmarkMembership.contains(gh) else { return }
bookmarks.insert(gh, at: 0)
bookmarkMembership.insert(gh)
persistBookmarks()
resolveBookmarkNameIfNeeded(for: gh)
}
func removeBookmark(_ geohash: String) {
let gh = Self.normalizeGeohash(geohash)
guard bookmarkMembership.contains(gh) else { return }
if let idx = bookmarks.firstIndex(of: gh) {
bookmarks.remove(at: idx)
}
bookmarkMembership.remove(gh)
if bookmarkNames.removeValue(forKey: gh) != nil {
persistBookmarkNames()
}
persistBookmarks()
}
// MARK: - CLLocationManagerDelegate
private func requestOneShotLocation() {
cl.requestLocation()
}
func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
updatePermissionState(from: status)
if case .authorized = permissionState {
requestOneShotLocation()
}
}
@available(iOS 14.0, macOS 11.0, *)
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
updatePermissionState(from: manager.authorizationStatus)
if case .authorized = permissionState {
requestOneShotLocation()
}
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let loc = locations.last else { return }
lastLocation = loc
computeChannels(from: loc.coordinate)
reverseGeocodeLocation(loc)
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
SecureLogger.error("LocationStateManager: location error: \(error.localizedDescription)", category: .session)
}
// MARK: - Private Helpers (Permission)
private func updatePermissionState(from status: CLAuthorizationStatus) {
let newState: PermissionState
switch status {
case .notDetermined: newState = .notDetermined
case .restricted: newState = .restricted
case .denied: newState = .denied
case .authorizedAlways, .authorizedWhenInUse, .authorized: newState = .authorized
@unknown default: newState = .restricted
}
Task { @MainActor in self.permissionState = newState }
}
// MARK: - Private Helpers (Channel Computation)
private func computeChannels(from coord: CLLocationCoordinate2D) {
let levels = GeohashChannelLevel.allCases
var result: [GeohashChannel] = []
for level in levels {
let gh = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: level.precision)
result.append(GeohashChannel(level: level, geohash: gh))
}
Task { @MainActor in
self.availableChannels = result
switch self.selectedChannel {
case .mesh:
self.teleported = false
case .location(let ch):
let inRegional = result.contains { $0.geohash == ch.geohash }
if inRegional {
self.teleported = false
if self.teleportedSet.contains(ch.geohash) {
self.teleportedSet.remove(ch.geohash)
self.persistTeleportedSet()
}
} else {
self.teleported = true
}
}
}
}
// MARK: - Private Helpers (Geocoding)
private func reverseGeocodeLocation(_ location: CLLocation) {
geocoder.cancelGeocode()
isGeocoding = true
geocoder.reverseGeocodeLocation(location) { [weak self] placemarks, _ in
guard let self = self else { return }
self.isGeocoding = false
if let pm = placemarks?.first {
let names = self.locationNamesByLevel(from: pm)
Task { @MainActor in self.locationNames = names }
}
}
}
private func locationNamesByLevel(from pm: CLPlacemark) -> [GeohashChannelLevel: String] {
var dict: [GeohashChannelLevel: String] = [:]
if let country = pm.country, !country.isEmpty {
dict[.region] = country
}
if let admin = pm.administrativeArea, !admin.isEmpty {
dict[.province] = admin
} else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {
dict[.province] = subAdmin
}
if let locality = pm.locality, !locality.isEmpty {
dict[.city] = locality
} else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {
dict[.city] = subAdmin
} else if let admin = pm.administrativeArea, !admin.isEmpty {
dict[.city] = admin
}
if let subLocality = pm.subLocality, !subLocality.isEmpty {
dict[.neighborhood] = subLocality
} else if let locality = pm.locality, !locality.isEmpty {
dict[.neighborhood] = locality
}
if let subLocality = pm.subLocality, !subLocality.isEmpty {
dict[.block] = subLocality
} else if let locality = pm.locality, !locality.isEmpty {
dict[.block] = locality
}
if let name = pm.name, !name.isEmpty {
dict[.building] = name
} else if let thoroughfare = pm.thoroughfare, !thoroughfare.isEmpty {
dict[.building] = thoroughfare
}
return dict
}
func resolveBookmarkNameIfNeeded(for geohash: String) {
let gh = Self.normalizeGeohash(geohash)
guard !gh.isEmpty, bookmarkNames[gh] == nil, !resolvingNames.contains(gh) else { return }
resolvingNames.insert(gh)
if gh.count <= 2 {
let b = Geohash.decodeBounds(gh)
let pts: [CLLocation] = [
CLLocation(latitude: (b.latMin + b.latMax) / 2, longitude: (b.lonMin + b.lonMax) / 2),
CLLocation(latitude: b.latMin, longitude: b.lonMin),
CLLocation(latitude: b.latMin, longitude: b.lonMax),
CLLocation(latitude: b.latMax, longitude: b.lonMin),
CLLocation(latitude: b.latMax, longitude: b.lonMax)
]
resolveCompositeAdminName(geohash: gh, points: pts)
} else {
let center = Geohash.decodeCenter(gh)
let loc = CLLocation(latitude: center.lat, longitude: center.lon)
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
guard let self = self else { return }
defer { self.resolvingNames.remove(gh) }
if let pm = placemarks?.first,
let name = Self.nameForGeohashLength(gh.count, from: pm),
!name.isEmpty {
DispatchQueue.main.async {
self.bookmarkNames[gh] = name
self.persistBookmarkNames()
}
}
}
}
}
private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) {
var uniqueAdmins: [String] = []
var seenAdmins = Set<String>()
var idx = 0
func step() {
if idx >= points.count {
let finalName: String? = {
if uniqueAdmins.count >= 2 { return uniqueAdmins[0] + " and " + uniqueAdmins[1] }
return uniqueAdmins.first
}()
if let finalName = finalName, !finalName.isEmpty {
DispatchQueue.main.async {
self.bookmarkNames[gh] = finalName
self.persistBookmarkNames()
}
}
self.resolvingNames.remove(gh)
return
}
let loc = points[idx]
idx += 1
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
guard self != nil else { return }
if let pm = placemarks?.first {
if let admin = pm.administrativeArea, !admin.isEmpty, !seenAdmins.contains(admin) {
seenAdmins.insert(admin)
uniqueAdmins.append(admin)
} else if let country = pm.country, !country.isEmpty, !seenAdmins.contains(country) {
seenAdmins.insert(country)
uniqueAdmins.append(country)
}
}
step()
}
}
step()
}
private static func nameForGeohashLength(_ len: Int, from pm: CLPlacemark) -> String? {
switch len {
case 0...2:
return pm.administrativeArea ?? pm.country
case 3...4:
return pm.administrativeArea ?? pm.subAdministrativeArea ?? pm.country
case 5:
return pm.locality ?? pm.subAdministrativeArea ?? pm.administrativeArea
case 6...7:
return pm.subLocality ?? pm.locality ?? pm.administrativeArea
default:
return pm.subLocality ?? pm.locality ?? pm.administrativeArea ?? pm.country
}
}
// MARK: - Private Helpers (Persistence)
private func persistTeleportedSet() {
if let data = try? JSONEncoder().encode(Array(teleportedSet)) {
storage.set(data, forKey: teleportedStoreKey)
}
}
private func persistBookmarks() {
if let data = try? JSONEncoder().encode(bookmarks) {
storage.set(data, forKey: bookmarksKey)
}
}
private func persistBookmarkNames() {
if let data = try? JSONEncoder().encode(bookmarkNames) {
storage.set(data, forKey: bookmarkNamesKey)
}
}
private static func normalizeGeohash(_ s: String) -> String {
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
return s
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
.replacingOccurrences(of: "#", with: "")
.filter { allowed.contains($0) }
}
}
// MARK: - Backward Compatibility Typealiases
typealias LocationChannelManager = LocationStateManager
typealias GeohashBookmarksStore = LocationStateManager
// MARK: - Backward Compatibility Extensions
extension LocationStateManager {
/// Backward compatibility: toggle bookmark (was GeohashBookmarksStore.toggle)
func toggle(_ geohash: String) {
toggleBookmark(geohash)
}
/// Backward compatibility: add bookmark (was GeohashBookmarksStore.add)
func add(_ geohash: String) {
addBookmark(geohash)
}
/// Backward compatibility: remove bookmark (was GeohashBookmarksStore.remove)
func remove(_ geohash: String) {
removeBookmark(geohash)
}
}
#endif
+120
View File
@@ -0,0 +1,120 @@
import Foundation
/// Tracks observed mesh topology and computes hop-by-hop routes.
final class MeshTopologyTracker {
private typealias RoutingID = Data
private let queue = DispatchQueue(label: "mesh.topology", attributes: .concurrent)
private let hopSize = 8
private var adjacency: [RoutingID: Set<RoutingID>] = [:]
func reset() {
queue.sync(flags: .barrier) {
self.adjacency.removeAll()
}
}
func recordDirectLink(between a: Data?, and b: Data?) {
guard let left = sanitize(a), let right = sanitize(b), left != right else { return }
queue.sync(flags: .barrier) {
var setA = self.adjacency[left] ?? []
setA.insert(right)
self.adjacency[left] = setA
var setB = self.adjacency[right] ?? []
setB.insert(left)
self.adjacency[right] = setB
}
}
func removeDirectLink(between a: Data?, and b: Data?) {
guard let left = sanitize(a), let right = sanitize(b), left != right else { return }
queue.sync(flags: .barrier) {
if var setA = self.adjacency[left] {
setA.remove(right)
self.adjacency[left] = setA.isEmpty ? nil : setA
}
if var setB = self.adjacency[right] {
setB.remove(left)
self.adjacency[right] = setB.isEmpty ? nil : setB
}
}
}
func removePeer(_ data: Data?) {
guard let peer = sanitize(data) else { return }
queue.sync(flags: .barrier) {
guard let neighbors = self.adjacency.removeValue(forKey: peer) else { return }
for neighbor in neighbors {
if var set = self.adjacency[neighbor] {
set.remove(peer)
self.adjacency[neighbor] = set.isEmpty ? nil : set
}
}
}
}
func recordRoute(_ hops: [Data]) {
let sanitized = hops.compactMap { sanitize($0) }
guard sanitized.count >= 2 else { return }
queue.sync(flags: .barrier) {
for idx in 0..<(sanitized.count - 1) {
let left = sanitized[idx]
let right = sanitized[idx + 1]
guard left != right else { continue }
var setA = self.adjacency[left] ?? []
setA.insert(right)
self.adjacency[left] = setA
var setB = self.adjacency[right] ?? []
setB.insert(left)
self.adjacency[right] = setB
}
}
}
func computeRoute(from start: Data?, to goal: Data?, maxHops: Int = 255) -> [Data]? {
guard let source = sanitize(start), let target = sanitize(goal) else { return nil }
if source == target { return [source] }
let graph = queue.sync { adjacency }
guard graph[source] != nil, graph[target] != nil else { return nil }
var visited: Set<RoutingID> = [source]
var queuePaths: [[RoutingID]] = [[source]]
var index = 0
while index < queuePaths.count {
let path = queuePaths[index]
index += 1
guard path.count <= maxHops else { continue }
guard let last = path.last, let neighbors = graph[last] else { continue }
for neighbor in neighbors {
if visited.contains(neighbor) { continue }
var nextPath = path
nextPath.append(neighbor)
if neighbor == target { return nextPath }
if nextPath.count <= maxHops {
queuePaths.append(nextPath)
}
visited.insert(neighbor)
}
}
return nil
}
// MARK: - Helpers
private func sanitize(_ data: Data?) -> Data? {
guard var value = data, !value.isEmpty else { return nil }
if value.count > hopSize {
value = Data(value.prefix(hopSize))
} else if value.count < hopSize {
value.append(Data(repeating: 0, count: hopSize - value.count))
}
return value
}
}
@@ -0,0 +1,274 @@
//
// MessageDeduplicationService.swift
// bitchat
//
// Handles message deduplication using LRU caches.
// This is free and unencumbered software released into the public domain.
//
import Foundation
// MARK: - LRU Deduplication Cache
/// Generic LRU (Least Recently Used) cache for deduplication.
/// Uses an efficient O(1) lookup with periodic compaction.
final class LRUDeduplicationCache<Value> {
private var map: [String: Value] = [:]
private var order: [String] = []
private var head: Int = 0
private let capacity: Int
/// Creates a new LRU cache with the specified capacity.
/// - Parameter capacity: Maximum number of entries before eviction
init(capacity: Int) {
precondition(capacity > 0, "LRU cache capacity must be positive")
self.capacity = capacity
}
/// Number of active entries in the cache
var count: Int {
order.count - head
}
/// Checks if a key exists in the cache
func contains(_ key: String) -> Bool {
map[key] != nil
}
/// Gets the value for a key, or nil if not present
func value(for key: String) -> Value? {
map[key]
}
/// Records a key-value pair, updating if exists or inserting if new
func record(_ key: String, value: Value) {
if map[key] == nil {
order.append(key)
}
map[key] = value
trimIfNeeded()
}
/// Removes a specific key from the cache
func remove(_ key: String) {
map.removeValue(forKey: key)
// Note: key remains in order array but will be skipped during eviction
}
/// Clears all entries from the cache
func clear() {
map.removeAll()
order.removeAll()
head = 0
}
// MARK: - Private
private func trimIfNeeded() {
let activeCount = order.count - head
guard activeCount > capacity else { return }
let overflow = activeCount - capacity
for _ in 0..<overflow {
guard let victim = popOldest() else { break }
map.removeValue(forKey: victim)
}
}
private func popOldest() -> String? {
// Skip keys that were already removed from map
while head < order.count {
let key = order[head]
head += 1
// Periodically compact the backing storage
if head >= 32 && head * 2 >= order.count {
order.removeFirst(head)
head = 0
}
// Only return if key is still in map
if map[key] != nil {
return key
}
}
return nil
}
}
// MARK: - Content Normalizer
/// Normalizes message content for near-duplicate detection.
enum ContentNormalizer {
/// Regex to simplify HTTP URLs by stripping query strings and fragments
private static let simplifyHTTPURL: NSRegularExpression = {
try! NSRegularExpression(
pattern: "https?://[^\\s?#]+(?:[?#][^\\s]*)?",
options: [.caseInsensitive]
)
}()
/// Normalizes content for deduplication comparison.
/// - Parameters:
/// - content: The raw message content
/// - prefixLength: Maximum characters to consider (default from TransportConfig)
/// - Returns: A hash-based key for comparison
static func normalizedKey(
_ content: String,
prefixLength: Int = TransportConfig.contentKeyPrefixLength
) -> String {
// Lowercase for case-insensitive comparison
let lowered = content.lowercased()
let ns = lowered as NSString
let range = NSRange(location: 0, length: ns.length)
// Simplify URLs by stripping query/fragment
var simplified = ""
var last = 0
for match in simplifyHTTPURL.matches(in: lowered, options: [], range: range) {
if match.range.location > last {
simplified += ns.substring(with: NSRange(location: last, length: match.range.location - last))
}
let url = ns.substring(with: match.range)
if let queryIndex = url.firstIndex(where: { $0 == "?" || $0 == "#" }) {
simplified += String(url[..<queryIndex])
} else {
simplified += url
}
last = match.range.location + match.range.length
}
if last < ns.length {
simplified += ns.substring(with: NSRange(location: last, length: ns.length - last))
}
// Trim and collapse whitespace
let trimmed = simplified.trimmingCharacters(in: .whitespacesAndNewlines)
let collapsed = trimmed.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
// Take prefix and hash
let prefix = String(collapsed.prefix(prefixLength))
let hash = prefix.djb2()
return String(format: "h:%016llx", hash)
}
}
// MARK: - Message Deduplication Service
/// Service that manages message deduplication using LRU caches.
/// Provides separate caches for content-based dedup and Nostr event ID dedup.
final class MessageDeduplicationService {
/// Cache for content-based near-duplicate detection
private let contentCache: LRUDeduplicationCache<Date>
/// Cache for Nostr event ID deduplication
private let nostrEventCache: LRUDeduplicationCache<Bool>
/// Cache for Nostr ACK deduplication (messageId:ackType:senderPubkey format)
private let nostrAckCache: LRUDeduplicationCache<Bool>
/// Creates a new deduplication service with specified capacities.
/// - Parameters:
/// - contentCapacity: Max entries for content cache
/// - nostrEventCapacity: Max entries for Nostr event cache
init(
contentCapacity: Int = TransportConfig.contentLRUCap,
nostrEventCapacity: Int = TransportConfig.uiProcessedNostrEventsCap
) {
self.contentCache = LRUDeduplicationCache(capacity: contentCapacity)
self.nostrEventCache = LRUDeduplicationCache(capacity: nostrEventCapacity)
self.nostrAckCache = LRUDeduplicationCache(capacity: nostrEventCapacity)
}
// MARK: - Content Deduplication
/// Records content with its timestamp for near-duplicate detection.
/// - Parameters:
/// - content: The message content
/// - timestamp: When the content was received
func recordContent(_ content: String, timestamp: Date) {
let key = ContentNormalizer.normalizedKey(content)
contentCache.record(key, value: timestamp)
}
/// Records a pre-normalized content key with its timestamp.
/// - Parameters:
/// - key: The normalized content key
/// - timestamp: When the content was received
func recordContentKey(_ key: String, timestamp: Date) {
contentCache.record(key, value: timestamp)
}
/// Gets the timestamp for previously seen content.
/// - Parameter content: The message content
/// - Returns: The timestamp when first seen, or nil if not seen
func contentTimestamp(for content: String) -> Date? {
let key = ContentNormalizer.normalizedKey(content)
return contentCache.value(for: key)
}
/// Gets the timestamp for a pre-normalized content key.
/// - Parameter key: The normalized content key
/// - Returns: The timestamp when first seen, or nil if not seen
func contentTimestamp(forKey key: String) -> Date? {
contentCache.value(for: key)
}
/// Normalizes content to a deduplication key.
/// - Parameter content: The raw content
/// - Returns: A normalized hash key
func normalizedContentKey(_ content: String) -> String {
ContentNormalizer.normalizedKey(content)
}
// MARK: - Nostr Event Deduplication
/// Checks if a Nostr event has already been processed.
/// - Parameter eventId: The event ID
/// - Returns: true if already processed
func hasProcessedNostrEvent(_ eventId: String) -> Bool {
nostrEventCache.contains(eventId)
}
/// Records a Nostr event as processed.
/// - Parameter eventId: The event ID
func recordNostrEvent(_ eventId: String) {
nostrEventCache.record(eventId, value: true)
}
// MARK: - Nostr ACK Deduplication
/// Checks if a Nostr ACK has already been processed.
/// - Parameter ackKey: The ACK key in format "messageId:ackType:senderPubkey"
/// - Returns: true if already processed
func hasProcessedNostrAck(_ ackKey: String) -> Bool {
nostrAckCache.contains(ackKey)
}
/// Records a Nostr ACK as processed.
/// - Parameter ackKey: The ACK key in format "messageId:ackType:senderPubkey"
func recordNostrAck(_ ackKey: String) {
nostrAckCache.record(ackKey, value: true)
}
/// Creates an ACK key from components.
static func ackKey(messageId: String, ackType: String, senderPubkey: String) -> String {
"\(messageId):\(ackType):\(senderPubkey)"
}
// MARK: - Clear
/// Clears all caches
func clearAll() {
contentCache.clear()
nostrEventCache.clear()
nostrAckCache.clear()
}
/// Clears only the Nostr caches (events and ACKs)
func clearNostrCaches() {
nostrEventCache.clear()
nostrAckCache.clear()
}
}
@@ -0,0 +1,471 @@
//
// MessageFormattingEngine.swift
// bitchat
//
// Handles message text formatting, including mentions, hashtags, URLs, and tokens.
// This is free and unencumbered software released into the public domain.
//
import Foundation
import SwiftUI
// MARK: - Formatting Context Protocol
/// Protocol defining the context needed for message formatting.
/// Implemented by ChatViewModel to provide runtime state.
@MainActor
protocol MessageFormattingContext: AnyObject {
/// The user's current nickname
var nickname: String { get }
/// Determines if a message was sent by the current user
func isSelfMessage(_ message: BitchatMessage) -> Bool
/// Gets the color for a message's sender
func senderColor(for message: BitchatMessage, isDark: Bool) -> Color
/// Resolves a peer ID to a clickable URL
func peerURL(for peerID: PeerID) -> URL?
}
// MARK: - Formatting Engine
/// Handles rich text formatting for chat messages.
/// Extracts mentions, hashtags, URLs, Lightning invoices, and Cashu tokens.
final class MessageFormattingEngine {
// MARK: - Precompiled Regexes
/// Precompiled regex patterns for message content parsing
enum Patterns {
static let hashtag: NSRegularExpression = {
try! NSRegularExpression(pattern: "#([a-zA-Z0-9_]+)", options: [])
}()
static let mention: NSRegularExpression = {
try! NSRegularExpression(pattern: "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)", options: [])
}()
static let cashu: NSRegularExpression = {
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
}()
static let bolt11: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: [])
}()
static let lnurl: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: [])
}()
static let lightningScheme: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: [])
}()
static let linkDetector: NSDataDetector? = {
try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
}()
static let quickCashuPresence: NSRegularExpression = {
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
}()
static let simplifyHTTPURL: NSRegularExpression = {
try! NSRegularExpression(pattern: "https?://[^\\s?#]+(?:[?#][^\\s]*)?", options: [.caseInsensitive])
}()
}
// MARK: - Match Types
/// Types of matches found in message content
enum MatchType: String {
case hashtag
case mention
case url
case cashu
case lightning
case bolt11
case lnurl
}
/// A match found in message content
struct ContentMatch {
let range: NSRange
let type: MatchType
}
// MARK: - Public API
/// Formats a message with rich text styling
@MainActor
static func formatMessage(
_ message: BitchatMessage,
context: MessageFormattingContext,
colorScheme: ColorScheme
) -> AttributedString {
let isDark = colorScheme == .dark
let isSelf = context.isSelfMessage(message)
// Check cache first
if let cached = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf) {
return cached
}
var result = AttributedString()
let baseColor: Color = isSelf ? .orange : context.senderColor(for: message, isDark: isDark)
// Format system messages differently
if message.sender == "system" {
result = formatSystemMessage(message, isDark: isDark)
} else {
// Format sender header
result = formatSenderHeader(
message: message,
baseColor: baseColor,
isSelf: isSelf,
context: context
)
// Format content
let contentResult = formatContent(
message.content,
baseColor: baseColor,
isSelf: isSelf,
isMentioned: message.mentions?.contains(context.nickname) ?? false
)
result.append(contentResult)
// Add timestamp
result.append(formatTimestamp(message.formattedTimestamp))
}
// Cache the result
message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf)
return result
}
/// Formats just the message header (sender portion)
@MainActor
static func formatHeader(
_ message: BitchatMessage,
context: MessageFormattingContext,
colorScheme: ColorScheme
) -> AttributedString {
let isDark = colorScheme == .dark
let isSelf = context.isSelfMessage(message)
let baseColor: Color = isSelf ? .orange : context.senderColor(for: message, isDark: isDark)
if message.sender == "system" {
var style = AttributeContainer()
style.foregroundColor = baseColor
style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
return AttributedString(message.sender).mergingAttributes(style)
}
return formatSenderHeader(
message: message,
baseColor: baseColor,
isSelf: isSelf,
context: context
)
}
/// Extracts mentions from message content
static func extractMentions(from content: String) -> [String] {
let nsContent = content as NSString
let range = NSRange(location: 0, length: nsContent.length)
let matches = Patterns.mention.matches(in: content, options: [], range: range)
return matches.compactMap { match -> String? in
guard match.numberOfRanges > 1 else { return nil }
let captureRange = match.range(at: 1)
guard let swiftRange = Range(captureRange, in: content) else { return nil }
return String(content[swiftRange])
}
}
/// Checks if content contains a Cashu token
static func containsCashuToken(_ content: String) -> Bool {
let nsContent = content as NSString
let range = NSRange(location: 0, length: nsContent.length)
return Patterns.quickCashuPresence.numberOfMatches(in: content, options: [], range: range) > 0
}
// MARK: - Private Helpers
private static func formatSystemMessage(_ message: BitchatMessage, isDark: Bool) -> AttributedString {
var result = AttributedString()
let content = AttributedString("* \(message.content) *")
var contentStyle = AttributeContainer()
contentStyle.foregroundColor = Color.gray
contentStyle.font = .bitchatSystem(size: 12, design: .monospaced).italic()
result.append(content.mergingAttributes(contentStyle))
// Add timestamp
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
var timestampStyle = AttributeContainer()
timestampStyle.foregroundColor = Color.gray.opacity(0.5)
timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)
result.append(timestamp.mergingAttributes(timestampStyle))
return result
}
@MainActor
private static func formatSenderHeader(
message: BitchatMessage,
baseColor: Color,
isSelf: Bool,
context: MessageFormattingContext
) -> AttributedString {
var result = AttributedString()
let (baseName, suffix) = message.sender.splitSuffix()
var senderStyle = AttributeContainer()
senderStyle.foregroundColor = baseColor
let fontWeight: Font.Weight = isSelf ? .bold : .medium
senderStyle.font = .bitchatSystem(size: 14, weight: fontWeight, design: .monospaced)
// Make sender clickable
if let spid = message.senderPeerID, let url = context.peerURL(for: spid) {
senderStyle.link = url
}
// Build: "<@baseName#suffix> "
result.append(AttributedString("<@").mergingAttributes(senderStyle))
result.append(AttributedString(baseName).mergingAttributes(senderStyle))
if !suffix.isEmpty {
var suffixStyle = senderStyle
suffixStyle.foregroundColor = baseColor.opacity(0.6)
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
}
result.append(AttributedString("> ").mergingAttributes(senderStyle))
return result
}
private static func formatContent(
_ content: String,
baseColor: Color,
isSelf: Bool,
isMentioned: Bool
) -> AttributedString {
// For very long content without special tokens, use plain formatting
let containsCashu = containsCashuToken(content)
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashu {
return formatPlainContent(content, baseColor: baseColor, isSelf: isSelf)
}
// Find all matches
let matches = findAllMatches(in: content)
// Build formatted content
var result = AttributedString()
var lastEnd = content.startIndex
for match in matches {
guard let swiftRange = Range(match.range, in: content) else { continue }
// Add text before match
if lastEnd < swiftRange.lowerBound {
let beforeText = String(content[lastEnd..<swiftRange.lowerBound])
result.append(formatPlainText(beforeText, baseColor: baseColor, isSelf: isSelf, isMentioned: isMentioned))
}
// Add styled match
let matchText = String(content[swiftRange])
result.append(formatMatch(matchText, type: match.type, baseColor: baseColor, isSelf: isSelf))
lastEnd = swiftRange.upperBound
}
// Add remaining text
if lastEnd < content.endIndex {
let remainingText = String(content[lastEnd...])
result.append(formatPlainText(remainingText, baseColor: baseColor, isSelf: isSelf, isMentioned: isMentioned))
}
return result
}
private static func findAllMatches(in content: String) -> [ContentMatch] {
let nsContent = content as NSString
let nsLen = nsContent.length
let fullRange = NSRange(location: 0, length: nsLen)
// Quick hints to avoid unnecessary regex work
let hasMentions = content.contains("@")
let hasHashtags = content.contains("#")
let hasURLs = content.contains("://") || content.contains("www.") || content.contains("http")
let hasLightning = content.lowercased().contains("ln") || content.lowercased().contains("lightning:")
let hasCashu = content.lowercased().contains("cashu")
// Collect matches
let mentionMatches = hasMentions ? Patterns.mention.matches(in: content, options: [], range: fullRange) : []
let hashtagMatches = hasHashtags ? Patterns.hashtag.matches(in: content, options: [], range: fullRange) : []
let urlMatches = hasURLs ? (Patterns.linkDetector?.matches(in: content, options: [], range: fullRange) ?? []) : []
let cashuMatches = hasCashu ? Patterns.cashu.matches(in: content, options: [], range: fullRange) : []
let lightningMatches = hasLightning ? Patterns.lightningScheme.matches(in: content, options: [], range: fullRange) : []
let bolt11Matches = hasLightning ? Patterns.bolt11.matches(in: content, options: [], range: fullRange) : []
let lnurlMatches = hasLightning ? Patterns.lnurl.matches(in: content, options: [], range: fullRange) : []
// Build mention ranges for overlap checking
let mentionRanges = mentionMatches.map { $0.range(at: 0) }
func overlapsMention(_ r: NSRange) -> Bool {
mentionRanges.contains { NSIntersectionRange(r, $0).length > 0 }
}
func isStandaloneHashtag(_ r: NSRange) -> Bool {
guard let swiftRange = Range(r, in: content) else { return false }
if swiftRange.lowerBound == content.startIndex { return true }
let prev = content.index(before: swiftRange.lowerBound)
return content[prev].isWhitespace || content[prev].isNewline
}
func attachedToMention(_ r: NSRange) -> Bool {
guard let swiftRange = Range(r, in: content), swiftRange.lowerBound > content.startIndex else { return false }
var i = content.index(before: swiftRange.lowerBound)
while true {
let ch = content[i]
if ch.isWhitespace || ch.isNewline { break }
if ch == "@" { return true }
if i == content.startIndex { break }
i = content.index(before: i)
}
return false
}
var allMatches: [ContentMatch] = []
// Add hashtags (excluding those attached to mentions)
for match in hashtagMatches {
let range = match.range(at: 0)
if !overlapsMention(range) && !attachedToMention(range) && isStandaloneHashtag(range) {
allMatches.append(ContentMatch(range: range, type: .hashtag))
}
}
// Add mentions
for match in mentionMatches {
allMatches.append(ContentMatch(range: match.range(at: 0), type: .mention))
}
// Add URLs
for match in urlMatches where !overlapsMention(match.range) {
allMatches.append(ContentMatch(range: match.range, type: .url))
}
// Add Cashu tokens
for match in cashuMatches where !overlapsMention(match.range(at: 0)) {
allMatches.append(ContentMatch(range: match.range(at: 0), type: .cashu))
}
// Add Lightning scheme URLs
for match in lightningMatches where !overlapsMention(match.range(at: 0)) {
allMatches.append(ContentMatch(range: match.range(at: 0), type: .lightning))
}
// Add bolt11/lnurl (avoiding overlaps with lightning scheme and URLs)
let occupied = urlMatches.map { $0.range } + lightningMatches.map { $0.range(at: 0) }
func overlapsOccupied(_ r: NSRange) -> Bool {
occupied.contains { NSIntersectionRange(r, $0).length > 0 }
}
for match in bolt11Matches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) {
allMatches.append(ContentMatch(range: match.range(at: 0), type: .bolt11))
}
for match in lnurlMatches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) {
allMatches.append(ContentMatch(range: match.range(at: 0), type: .lnurl))
}
// Sort by position
return allMatches.sorted { $0.range.location < $1.range.location }
}
private static func formatPlainContent(_ content: String, baseColor: Color, isSelf: Bool) -> AttributedString {
var style = AttributeContainer()
style.foregroundColor = baseColor
style.font = isSelf
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
: .bitchatSystem(size: 14, design: .monospaced)
return AttributedString(content).mergingAttributes(style)
}
private static func formatPlainText(_ text: String, baseColor: Color, isSelf: Bool, isMentioned: Bool) -> AttributedString {
guard !text.isEmpty else { return AttributedString() }
var style = AttributeContainer()
style.foregroundColor = baseColor
style.font = isSelf
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
: .bitchatSystem(size: 14, design: .monospaced)
if isMentioned {
style.font = style.font?.bold()
}
return AttributedString(text).mergingAttributes(style)
}
private static func formatMatch(_ text: String, type: MatchType, baseColor: Color, isSelf: Bool) -> AttributedString {
var style = AttributeContainer()
switch type {
case .mention:
// Split optional '#abcd' suffix
let (baseName, suffix) = text.splitSuffix()
var result = AttributedString()
var mentionStyle = AttributeContainer()
mentionStyle.foregroundColor = .blue
mentionStyle.font = .bitchatSystem(size: 14, weight: .semibold, design: .monospaced)
result.append(AttributedString(baseName).mergingAttributes(mentionStyle))
if !suffix.isEmpty {
var suffixStyle = mentionStyle
suffixStyle.foregroundColor = Color.gray.opacity(0.7)
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
}
return result
case .hashtag:
style.foregroundColor = .purple
style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
case .url:
style.foregroundColor = .blue
style.font = .bitchatSystem(size: 14, design: .monospaced)
style.underlineStyle = .single
if let url = URL(string: text) {
style.link = url
}
case .cashu:
style.foregroundColor = .green
style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
style.backgroundColor = Color.green.opacity(0.1)
case .lightning, .bolt11, .lnurl:
style.foregroundColor = .yellow
style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
style.backgroundColor = Color.yellow.opacity(0.1)
}
return AttributedString(text).mergingAttributes(style)
}
private static func formatTimestamp(_ timestamp: String) -> AttributedString {
let text = AttributedString(" [\(timestamp)]")
var style = AttributeContainer()
style.foregroundColor = Color.gray.opacity(0.5)
style.font = .bitchatSystem(size: 10, design: .monospaced)
return text.mergingAttributes(style)
}
}
+56 -69
View File
@@ -1,16 +1,14 @@
import BitLogger
import Foundation
/// Routes messages between BLE and Nostr transports
/// Routes messages using available transports (Mesh, Nostr, etc.)
@MainActor
final class MessageRouter {
private let mesh: Transport
private let nostr: NostrTransport
private var outbox: [String: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
private let transports: [Transport]
private var outbox: [PeerID: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
init(mesh: Transport, nostr: NostrTransport) {
self.mesh = mesh
self.nostr = nostr
self.nostr.senderPeerID = mesh.myPeerID
init(transports: [Transport]) {
self.transports = transports
// Observe favorites changes to learn Nostr mapping and flush queued messages
NotificationCenter.default.addObserver(
@@ -20,7 +18,7 @@ final class MessageRouter {
) { [weak self] note in
guard let self = self else { return }
if let data = note.userInfo?["peerPublicKey"] as? Data {
let peerID = PeerIDUtils.derivePeerID(fromPublicKey: data)
let peerID = PeerID(publicKey: data)
Task { @MainActor in
self.flushOutbox(for: peerID)
}
@@ -28,7 +26,7 @@ final class MessageRouter {
// Handle key updates
if let newKey = note.userInfo?["peerPublicKey"] as? Data,
let _ = note.userInfo?["isKeyUpdate"] as? Bool {
let peerID = PeerIDUtils.derivePeerID(fromPublicKey: newKey)
let peerID = PeerID(publicKey: newKey)
Task { @MainActor in
self.flushOutbox(for: peerID)
}
@@ -36,90 +34,79 @@ final class MessageRouter {
}
}
func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
let reachableMesh = mesh.isPeerReachable(peerID)
if reachableMesh {
SecureLogger.log("Routing PM via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))",
category: SecureLogger.session, level: .debug)
// BLEService will initiate a handshake if needed and queue the message
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
} else if canSendViaNostr(peerID: peerID) {
SecureLogger.log("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))",
category: SecureLogger.session, level: .debug)
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
// Try to find a reachable transport
if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
SecureLogger.debug("Routing PM via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
} else {
// Queue for later (when mesh connects or Nostr mapping appears)
// Queue for later
if outbox[peerID] == nil { outbox[peerID] = [] }
outbox[peerID]?.append((content, recipientNickname, messageID))
SecureLogger.log("Queued PM for \(peerID.prefix(8))… (no mesh, no Nostr mapping) id=\(messageID.prefix(8))",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))", category: .session)
}
}
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
// Prefer mesh for reachable peers; BLE will queue if handshake is needed
if mesh.isPeerReachable(peerID) {
SecureLogger.log("Routing READ ack via mesh (reachable) to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))",
category: SecureLogger.session, level: .debug)
mesh.sendReadReceipt(receipt, to: peerID)
} else {
SecureLogger.log("Routing READ ack via Nostr to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))",
category: SecureLogger.session, level: .debug)
nostr.sendReadReceipt(receipt, to: peerID)
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
SecureLogger.debug("Routing READ ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))", category: .session)
transport.sendReadReceipt(receipt, to: peerID)
} else if !transports.isEmpty {
// Fallback to last transport (usually Nostr) if neither is explicitly reachable?
// Or better: just try the first one that supports it?
// Existing logic preferred mesh, then nostr.
// If neither reachable, existing logic queued it (via mesh usually) or sent via nostr.
// Let's stick to "try reachable". If none, maybe pick the first one to queue?
// Actually, for READ receipts, we might want to just fire-and-forget on the "best effort" transport.
// But let's stick to the reachable check.
SecureLogger.debug("No reachable transport for READ ack to \(peerID.id.prefix(8))", category: .session)
}
}
func sendDeliveryAck(_ messageID: String, to peerID: String) {
if mesh.isPeerReachable(peerID) {
SecureLogger.log("Routing DELIVERED ack via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))",
category: SecureLogger.session, level: .debug)
mesh.sendDeliveryAck(for: messageID, to: peerID)
} else {
nostr.sendDeliveryAck(for: messageID, to: peerID)
func sendDeliveryAck(_ messageID: String, to peerID: PeerID) {
if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
SecureLogger.debug("Routing DELIVERED ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
transport.sendDeliveryAck(for: messageID, to: peerID)
}
}
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
if mesh.isPeerConnected(peerID) {
mesh.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
if let transport = transports.first(where: { $0.isPeerConnected(peerID) }) {
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
} else if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
} else {
nostr.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
// Fallback: try all? or just the last one?
// Old logic: if mesh connected, mesh. Else nostr.
// Note: NostrTransport.isPeerReachable now returns true if mapped.
// If not mapped, we can't send via Nostr anyway.
}
}
// MARK: - Outbox Management
private func canSendViaNostr(peerID: String) -> Bool {
guard let noiseKey = Data(hexString: peerID) else { return false }
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
fav.peerNostrPublicKey != nil {
return true
}
return false
}
func flushOutbox(for peerID: String) {
func flushOutbox(for peerID: PeerID) {
guard let queued = outbox[peerID], !queued.isEmpty else { return }
SecureLogger.log("Flushing outbox for \(peerID.prefix(8))… count=\(queued.count)",
category: SecureLogger.session, level: .debug)
// Prefer mesh if connected; else try Nostr if mapping exists
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
var remaining: [(content: String, nickname: String, messageID: String)] = []
for (content, nickname, messageID) in queued {
if mesh.isPeerReachable(peerID) {
SecureLogger.log("Outbox -> mesh for \(peerID.prefix(8))… id=\(messageID.prefix(8))",
category: SecureLogger.session, level: .debug)
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
} else if canSendViaNostr(peerID: peerID) {
SecureLogger.log("Outbox -> Nostr for \(peerID.prefix(8))… id=\(messageID.prefix(8))",
category: SecureLogger.session, level: .debug)
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
SecureLogger.debug("Outbox -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
transport.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
} else {
continue
remaining.append((content, nickname, messageID))
}
}
// Remove all flushed items (remaining ones, if any, will be re-queued on next call)
outbox[peerID]?.removeAll()
if remaining.isEmpty {
outbox.removeValue(forKey: peerID)
} else {
outbox[peerID] = remaining
}
}
func flushAllOutbox() {
for key in outbox.keys { flushOutbox(for: key) }
for key in Array(outbox.keys) { flushOutbox(for: key) }
}
}
@@ -0,0 +1,114 @@
import Foundation
import BitLogger
import Combine
import Tor
/// Coordinates when the app is allowed to start Tor and connect to Nostr relays.
/// Policy: permit start when either location permissions are authorized OR
/// there exists at least one mutual favorite. Otherwise, do not start.
@MainActor
final class NetworkActivationService: ObservableObject {
static let shared = NetworkActivationService()
@Published private(set) var activationAllowed: Bool = false
@Published private(set) var userTorEnabled: Bool = true
private var cancellables = Set<AnyCancellable>()
private var started = false
private let torPreferenceKey = "networkActivationService.userTorEnabled"
private var torAutoStartDesired: Bool = false
private init() {}
func start() {
guard !started else { return }
started = true
if let stored = UserDefaults.standard.object(forKey: torPreferenceKey) as? Bool {
userTorEnabled = stored
} else {
userTorEnabled = true
}
// Initial compute
let allowed = basePolicyAllowed()
activationAllowed = allowed
torAutoStartDesired = allowed && userTorEnabled
TorManager.shared.setAutoStartAllowed(torAutoStartDesired)
applyTorState(torDesired: torAutoStartDesired)
if allowed {
NostrRelayManager.shared.connect()
} else {
NostrRelayManager.shared.disconnect()
}
// React to location permission changes
LocationChannelManager.shared.$permissionState
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.reevaluate()
}
.store(in: &cancellables)
// React to mutual favorites changes
FavoritesPersistenceService.shared.$mutualFavorites
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.reevaluate()
}
.store(in: &cancellables)
}
func setUserTorEnabled(_ enabled: Bool) {
guard enabled != userTorEnabled else { return }
userTorEnabled = enabled
UserDefaults.standard.set(enabled, forKey: torPreferenceKey)
NotificationCenter.default.post(
name: .TorUserPreferenceChanged,
object: nil,
userInfo: ["enabled": enabled]
)
reevaluate()
}
private func reevaluate() {
let allowed = basePolicyAllowed()
let torDesired = allowed && userTorEnabled
let statusChanged = allowed != activationAllowed
let torChanged = torDesired != torAutoStartDesired
if statusChanged {
SecureLogger.info("NetworkActivationService: activationAllowed -> \(allowed)", category: .session)
activationAllowed = allowed
}
if statusChanged || torChanged {
torAutoStartDesired = torDesired
TorManager.shared.setAutoStartAllowed(torDesired)
applyTorState(torDesired: torDesired)
}
if allowed {
if torChanged {
// Reset relay sockets when switching transport path (Tor direct)
NostrRelayManager.shared.disconnect()
}
NostrRelayManager.shared.connect()
} else if statusChanged {
NostrRelayManager.shared.disconnect()
}
}
private func basePolicyAllowed() -> Bool {
let permOK = LocationChannelManager.shared.permissionState == .authorized
let hasMutual = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
return permOK || hasMutual
}
private func applyTorState(torDesired: Bool) {
TorURLSession.shared.setProxyMode(useTor: torDesired)
if torDesired {
TorManager.shared.startIfNeeded()
} else {
TorManager.shared.shutdownCompletely()
}
}
}
+75 -76
View File
@@ -62,7 +62,6 @@
/// ## Integration Points
/// - **BLEService**: Calls this service for all private messages
/// - **ChatViewModel**: Monitors encryption status for UI indicators
/// - **NoiseHandshakeCoordinator**: Prevents handshake race conditions
/// - **KeychainManager**: Secure storage for identity keys
///
/// ## Thread Safety
@@ -83,9 +82,9 @@
/// - Background queue for CPU-intensive operations
///
import BitLogger
import Foundation
import CryptoKit
import os.log
// MARK: - Encryption Status
@@ -116,15 +115,30 @@ enum EncryptionStatus: Equatable {
var description: String {
switch self {
case .none:
return "Encryption failed"
return String(localized: "encryption.status.failed", comment: "Status text when encryption failed")
case .noHandshake:
return "Not encrypted"
return String(localized: "encryption.status.not_encrypted", comment: "Status text when no encryption handshake happened")
case .noiseHandshaking:
return "Establishing encryption..."
return String(localized: "encryption.status.establishing", comment: "Status text when encryption is being established")
case .noiseSecured:
return "Encrypted"
return String(localized: "encryption.status.secured", comment: "Status text when encryption is secured but not verified")
case .noiseVerified:
return "Encrypted & Verified"
return String(localized: "encryption.status.verified", comment: "Status text when encryption is verified")
}
}
var accessibilityDescription: String {
switch self {
case .none:
return String(localized: "encryption.accessibility.failed", comment: "Accessibility text when encryption failed")
case .noHandshake:
return String(localized: "encryption.accessibility.not_encrypted", comment: "Accessibility text when encryption is not established")
case .noiseHandshaking:
return String(localized: "encryption.accessibility.establishing", comment: "Accessibility text when encryption is being established")
case .noiseSecured:
return String(localized: "encryption.accessibility.secured", comment: "Accessibility text when encryption is secured")
case .noiseVerified:
return String(localized: "encryption.accessibility.verified", comment: "Accessibility text when encryption is verified")
}
}
}
@@ -135,7 +149,7 @@ enum EncryptionStatus: Equatable {
/// Provides a high-level API for establishing secure channels between peers,
/// handling all cryptographic operations transparently.
/// - Important: This service maintains the device's cryptographic identity
class NoiseEncryptionService {
final class NoiseEncryptionService {
// Static identity key (persistent across sessions)
private let staticIdentityKey: Curve25519.KeyAgreement.PrivateKey
public let staticIdentityPublicKey: Curve25519.KeyAgreement.PublicKey
@@ -148,32 +162,33 @@ class NoiseEncryptionService {
private let sessionManager: NoiseSessionManager
// Peer fingerprints (SHA256 hash of static public key)
private var peerFingerprints: [String: String] = [:] // peerID -> fingerprint
private var fingerprintToPeerID: [String: String] = [:] // fingerprint -> peerID
private var peerFingerprints: [PeerID: String] = [:]
private var fingerprintToPeerID: [String: PeerID] = [:]
// Thread safety
private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent)
// Security components
private let rateLimiter = NoiseRateLimiter()
private let keychain: KeychainManagerProtocol
// Session maintenance
private var rekeyTimer: Timer?
private let rekeyCheckInterval: TimeInterval = 60.0 // Check every minute
// Callbacks
private var onPeerAuthenticatedHandlers: [((String, String) -> Void)] = [] // Array of handlers for peer authentication
var onHandshakeRequired: ((String) -> Void)? // peerID needs handshake
private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
// Add a handler for peer authentication
func addOnPeerAuthenticatedHandler(_ handler: @escaping (String, String) -> Void) {
func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) {
serviceQueue.async(flags: .barrier) { [weak self] in
self?.onPeerAuthenticatedHandlers.append(handler)
}
}
// Legacy support - setting this will add to the handlers array
var onPeerAuthenticated: ((String, String) -> Void)? {
var onPeerAuthenticated: ((PeerID, String) -> Void)? {
get { nil } // Always return nil for backward compatibility
set {
if let handler = newValue {
@@ -182,15 +197,17 @@ class NoiseEncryptionService {
}
}
init() {
init(keychain: KeychainManagerProtocol) {
self.keychain = keychain
// Load or create static identity key (ONLY from keychain)
let loadedKey: Curve25519.KeyAgreement.PrivateKey
// 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) {
loadedKey = key
SecureLogger.logKeyOperation("load", keyType: "noiseStaticKey", success: true)
SecureLogger.logKeyOperation(.load, keyType: "noiseStaticKey", success: true)
}
// If no identity exists, create new one
else {
@@ -198,8 +215,8 @@ class NoiseEncryptionService {
let keyData = loadedKey.rawRepresentation
// Save to keychain
let saved = KeychainManager.shared.saveIdentityKey(keyData, forKey: "noiseStaticKey")
SecureLogger.logKeyOperation("create", keyType: "noiseStaticKey", success: saved)
let saved = keychain.saveIdentityKey(keyData, forKey: "noiseStaticKey")
SecureLogger.logKeyOperation(.create, keyType: "noiseStaticKey", success: saved)
}
// Now assign the final value
@@ -210,10 +227,10 @@ class NoiseEncryptionService {
let loadedSigningKey: Curve25519.Signing.PrivateKey
// 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) {
loadedSigningKey = key
SecureLogger.logKeyOperation("load", keyType: "ed25519SigningKey", success: true)
SecureLogger.logKeyOperation(.load, keyType: "ed25519SigningKey", success: true)
}
// If no signing key exists, create new one
else {
@@ -221,8 +238,8 @@ class NoiseEncryptionService {
let keyData = loadedSigningKey.rawRepresentation
// Save to keychain
let saved = KeychainManager.shared.saveIdentityKey(keyData, forKey: "ed25519SigningKey")
SecureLogger.logKeyOperation("create", keyType: "ed25519SigningKey", success: saved)
let saved = keychain.saveIdentityKey(keyData, forKey: "ed25519SigningKey")
SecureLogger.logKeyOperation(.create, keyType: "ed25519SigningKey", success: saved)
}
// Now assign the signing keys
@@ -230,7 +247,7 @@ class NoiseEncryptionService {
self.signingPublicKey = signingKey.publicKey
// Initialize session manager
self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey)
self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain)
// Set up session callbacks
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in
@@ -255,22 +272,21 @@ class NoiseEncryptionService {
/// Get our identity fingerprint
func getIdentityFingerprint() -> String {
let hash = SHA256.hash(data: staticIdentityPublicKey.rawRepresentation)
return hash.map { String(format: "%02x", $0) }.joined()
staticIdentityPublicKey.rawRepresentation.sha256Fingerprint()
}
/// Get peer's public key data
func getPeerPublicKeyData(_ peerID: String) -> Data? {
func getPeerPublicKeyData(_ peerID: PeerID) -> Data? {
return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation
}
/// Clear persistent identity (for panic mode)
func clearPersistentIdentity() {
// Clear from keychain
let deletedStatic = KeychainManager.shared.deleteIdentityKey(forKey: "noiseStaticKey")
let deletedSigning = KeychainManager.shared.deleteIdentityKey(forKey: "ed25519SigningKey")
SecureLogger.logKeyOperation("delete", keyType: "identity keys", success: deletedStatic && deletedSigning)
SecureLogger.log("Panic mode activated - identity cleared", category: SecureLogger.security, level: .warning)
let deletedStatic = keychain.deleteIdentityKey(forKey: "noiseStaticKey")
let deletedSigning = keychain.deleteIdentityKey(forKey: "ed25519SigningKey")
SecureLogger.logKeyOperation(.delete, keyType: "identity keys", success: deletedStatic && deletedSigning)
SecureLogger.warning("Panic mode activated - identity cleared", category: .security)
// Stop rekey timer
stopRekeyTimer()
}
@@ -281,7 +297,7 @@ class NoiseEncryptionService {
let signature = try signingKey.signature(for: data)
return signature
} catch {
SecureLogger.logError(error, context: "Failed to sign data", category: SecureLogger.noise)
SecureLogger.error(error, context: "Failed to sign data")
return nil
}
}
@@ -292,7 +308,7 @@ class NoiseEncryptionService {
let signingPublicKey = try Curve25519.Signing.PublicKey(rawRepresentation: publicKey)
return signingPublicKey.isValidSignature(signature, for: data)
} catch {
SecureLogger.logError(error, context: "Failed to verify signature", category: SecureLogger.noise)
SecureLogger.error(error, context: "Failed to verify signature")
return false
}
}
@@ -388,21 +404,21 @@ class NoiseEncryptionService {
// MARK: - Handshake Management
/// Initiate a Noise handshake with a peer
func initiateHandshake(with peerID: String) throws -> Data {
func initiateHandshake(with peerID: PeerID) throws -> Data {
// Validate peer ID
guard NoiseSecurityValidator.validatePeerID(peerID) else {
SecureLogger.logSecurityEvent(.authenticationFailed(peerID: peerID), level: .warning)
guard peerID.isValid else {
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
throw NoiseSecurityError.invalidPeerID
}
// Check rate limit
guard rateLimiter.allowHandshake(from: peerID) else {
SecureLogger.logSecurityEvent(.authenticationFailed(peerID: "Rate limited: \(peerID)"), level: .warning)
SecureLogger.warning(.authenticationFailed(peerID: "Rate limited: \(peerID)"))
throw NoiseSecurityError.rateLimitExceeded
}
SecureLogger.logSecurityEvent(.handshakeStarted(peerID: peerID))
SecureLogger.info(.handshakeStarted(peerID: peerID.id))
// Return raw handshake data without wrapper
// The Noise protocol handles its own message format
@@ -411,23 +427,23 @@ class NoiseEncryptionService {
}
/// Process an incoming handshake message
func processHandshakeMessage(from peerID: String, message: Data) throws -> Data? {
func processHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? {
// Validate peer ID
guard NoiseSecurityValidator.validatePeerID(peerID) else {
SecureLogger.logSecurityEvent(.authenticationFailed(peerID: peerID), level: .warning)
guard peerID.isValid else {
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
throw NoiseSecurityError.invalidPeerID
}
// Validate message size
guard NoiseSecurityValidator.validateHandshakeMessageSize(message) else {
SecureLogger.logSecurityEvent(.handshakeFailed(peerID: peerID, error: "Message too large"), level: .warning)
SecureLogger.warning(.handshakeFailed(peerID: peerID.id, error: "Message too large"))
throw NoiseSecurityError.messageTooLarge
}
// Check rate limit
guard rateLimiter.allowHandshake(from: peerID) else {
SecureLogger.logSecurityEvent(.authenticationFailed(peerID: "Rate limited: \(peerID)"), level: .warning)
SecureLogger.warning(.authenticationFailed(peerID: "Rate limited: \(peerID)"))
throw NoiseSecurityError.rateLimitExceeded
}
@@ -441,19 +457,19 @@ class NoiseEncryptionService {
}
/// Check if we have an established session with a peer
func hasEstablishedSession(with peerID: String) -> Bool {
func hasEstablishedSession(with peerID: PeerID) -> Bool {
return sessionManager.getSession(for: peerID)?.isEstablished() ?? false
}
/// Check if we have a session (established or handshaking) with a peer
func hasSession(with peerID: String) -> Bool {
func hasSession(with peerID: PeerID) -> Bool {
return sessionManager.getSession(for: peerID) != nil
}
// MARK: - Encryption/Decryption
/// Encrypt data for a specific peer
func encrypt(_ data: Data, for peerID: String) throws -> Data {
func encrypt(_ data: Data, for peerID: PeerID) throws -> Data {
// Validate message size
guard NoiseSecurityValidator.validateMessageSize(data) else {
throw NoiseSecurityError.messageTooLarge
@@ -475,7 +491,7 @@ class NoiseEncryptionService {
}
/// Decrypt data from a specific peer
func decrypt(_ data: Data, from peerID: String) throws -> Data {
func decrypt(_ data: Data, from peerID: PeerID) throws -> Data {
// Validate message size
guard NoiseSecurityValidator.validateMessageSize(data) else {
throw NoiseSecurityError.messageTooLarge
@@ -497,38 +513,26 @@ class NoiseEncryptionService {
// MARK: - Peer Management
/// Get fingerprint for a peer
func getPeerFingerprint(_ peerID: String) -> String? {
func getPeerFingerprint(_ peerID: PeerID) -> String? {
return serviceQueue.sync {
return peerFingerprints[peerID]
}
}
/// Get peer ID for a fingerprint
func getPeerID(for fingerprint: String) -> String? {
return serviceQueue.sync {
return fingerprintToPeerID[fingerprint]
}
}
/// Remove a peer session
func removePeer(_ peerID: String) {
sessionManager.removeSession(for: peerID)
func clearEphemeralStateForPanic() {
sessionManager.removeAllSessions()
serviceQueue.sync(flags: .barrier) {
if let fingerprint = peerFingerprints[peerID] {
fingerprintToPeerID.removeValue(forKey: fingerprint)
}
peerFingerprints.removeValue(forKey: peerID)
peerFingerprints.removeAll()
fingerprintToPeerID.removeAll()
}
SecureLogger.logSecurityEvent(.sessionExpired(peerID: peerID))
rateLimiter.resetAll()
}
// MARK: - Private Helpers
private func handleSessionEstablished(peerID: String, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
private func handleSessionEstablished(peerID: PeerID, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
// Calculate fingerprint
let fingerprint = calculateFingerprint(for: remoteStaticKey)
let fingerprint = remoteStaticKey.rawRepresentation.sha256Fingerprint()
// Store fingerprint mapping
serviceQueue.sync(flags: .barrier) {
@@ -537,7 +541,7 @@ class NoiseEncryptionService {
}
// Log security event
SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID))
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
// Notify all handlers about authentication
serviceQueue.async { [weak self] in
@@ -546,11 +550,6 @@ class NoiseEncryptionService {
}
}
}
private func calculateFingerprint(for publicKey: Curve25519.KeyAgreement.PublicKey) -> String {
let hash = SHA256.hash(data: publicKey.rawRepresentation)
return hash.map { String(format: "%02x", $0) }.joined()
}
// MARK: - Session Maintenance
@@ -573,12 +572,12 @@ class NoiseEncryptionService {
// Attempt to rekey the session
do {
try sessionManager.initiateRekey(for: peerID)
SecureLogger.log("Key rotation initiated for peer: \(peerID)", category: SecureLogger.security, level: .debug)
SecureLogger.debug("Key rotation initiated for peer: \(peerID)", category: .security)
// Signal that handshake is needed
onHandshakeRequired?(peerID)
} catch {
SecureLogger.logError(error, context: "Failed to initiate rekey for peer: \(peerID)", category: SecureLogger.session)
SecureLogger.error(error, context: "Failed to initiate rekey for peer: \(peerID)", category: .session)
}
}
}
+223 -148
View File
@@ -1,28 +1,79 @@
import BitLogger
import Foundation
import Combine
// Minimal Nostr transport conforming to Transport for offline sending
final class NostrTransport: Transport {
final class NostrTransport: Transport, @unchecked Sendable {
// Provide BLE short peer ID for BitChat embedding
var senderPeerID = PeerID(str: "")
// Throttle READ receipts to avoid relay rate limits
private struct QueuedRead {
let receipt: ReadReceipt
let peerID: PeerID
}
private var readQueue: [QueuedRead] = []
private var isSendingReadAcks = false
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
private let keychain: KeychainManagerProtocol
private let idBridge: NostrIdentityBridge
// Reachability Cache (thread-safe)
private var reachablePeers: Set<PeerID> = []
private let queue = DispatchQueue(label: "nostr.transport.state", attributes: .concurrent)
@MainActor
init(keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge) {
self.keychain = keychain
self.idBridge = idBridge
setupObservers()
// Synchronously warm the cache to avoid startup race
let favorites = FavoritesPersistenceService.shared.favorites
let reachable = favorites.values
.filter { $0.peerNostrPublicKey != nil }
.map { PeerID(publicKey: $0.peerNoisePublicKey) }
queue.sync(flags: .barrier) {
self.reachablePeers = Set(reachable)
}
}
private func setupObservers() {
NotificationCenter.default.addObserver(
forName: .favoriteStatusChanged,
object: nil,
queue: nil
) { [weak self] _ in
self?.refreshReachablePeers()
}
}
private func refreshReachablePeers() {
Task { @MainActor in
let favorites = FavoritesPersistenceService.shared.favorites
let reachable = favorites.values
.filter { $0.peerNostrPublicKey != nil }
.map { PeerID(publicKey: $0.peerNoisePublicKey) }
self.queue.async(flags: .barrier) { [weak self] in
self?.reachablePeers = Set(reachable)
}
}
}
// MARK: - Transport Protocol Conformance
weak var delegate: BitchatDelegate?
weak var peerEventsDelegate: TransportPeerEventsDelegate?
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
Just([]).eraseToAnyPublisher()
}
func currentPeerSnapshots() -> [TransportPeerSnapshot] { [] }
// Provide BLE short peer ID for BitChat embedding
var senderPeerID: String = ""
// Throttle READ receipts to avoid relay rate limits
private struct QueuedRead {
let receipt: ReadReceipt
let peerID: String
}
private var readQueue: [QueuedRead] = []
private var isSendingReadAcks = false
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
var myPeerID: String { senderPeerID }
var myPeerID: PeerID { senderPeerID }
var myNickname: String { "" }
func setNickname(_ nickname: String) { /* not used for Nostr */ }
@@ -30,62 +81,185 @@ final class NostrTransport: Transport {
func stopServices() { /* no-op */ }
func emergencyDisconnectAll() { /* no-op */ }
func isPeerConnected(_ peerID: String) -> Bool { false }
func isPeerReachable(_ peerID: String) -> Bool { false }
func peerNickname(peerID: String) -> String? { nil }
func getPeerNicknames() -> [String : String] { [:] }
func isPeerConnected(_ peerID: PeerID) -> Bool { false }
func isPeerReachable(_ peerID: PeerID) -> Bool {
queue.sync {
// Check if exact match
if reachablePeers.contains(peerID) { return true }
// Check for short ID match
if peerID.isShort {
return reachablePeers.contains(where: { $0.toShort() == peerID })
}
return false
}
}
func peerNickname(peerID: PeerID) -> String? { nil }
func getPeerNicknames() -> [PeerID : String] { [:] }
func getFingerprint(for peerID: String) -> String? { nil }
func getNoiseSessionState(for peerID: String) -> LazyHandshakeState { .none }
func triggerHandshake(with peerID: String) { /* no-op */ }
func getFingerprint(for peerID: PeerID) -> String? { nil }
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }
func triggerHandshake(with peerID: PeerID) { /* no-op */ }
// Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation
private static var cachedNoiseService: NoiseEncryptionService = {
NoiseEncryptionService()
}()
func getNoiseService() -> NoiseEncryptionService { Self.cachedNoiseService }
private static var cachedNoiseService: NoiseEncryptionService?
func getNoiseService() -> NoiseEncryptionService {
if let noiseService = Self.cachedNoiseService {
return noiseService
}
let noiseService = NoiseEncryptionService(keychain: keychain)
Self.cachedNoiseService = noiseService
return noiseService
}
// Public broadcast not supported over Nostr here
func sendMessage(_ content: String, mentions: [String]) { /* no-op */ }
func sendPrivateMessage(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
SecureLogger.log("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.prefix(8))… id=\(messageID.prefix(8))",
category: SecureLogger.session, level: .debug)
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
// Convert recipient npub -> hex (x-only)
let recipientHex: String
do {
let (hrp, data) = try Bech32.decode(recipientNpub)
guard hrp == "npub" else {
SecureLogger.log("NostrTransport: recipient key not npub (hrp=\(hrp))", category: SecureLogger.session, level: .error)
SecureLogger.error("NostrTransport: recipient key not npub (hrp=\(hrp))", category: .session)
return
}
recipientHex = data.hexEncodedString()
} catch {
SecureLogger.log("NostrTransport: failed to decode npub -> hex: \(error)", category: SecureLogger.session, level: .error)
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
return
}
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
SecureLogger.log("NostrTransport: failed to embed PM packet", category: SecureLogger.session, level: .error)
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
return
}
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.log("NostrTransport: failed to build Nostr event for PM", category: SecureLogger.session, level: .error)
SecureLogger.error("NostrTransport: failed to build Nostr event for PM", category: .session)
return
}
SecureLogger.log("NostrTransport: sending PM giftWrap id=\(event.id.prefix(16))",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("NostrTransport: sending PM giftWrap id=\(event.id.prefix(16))", category: .session)
NostrRelayManager.shared.sendEvent(event)
}
}
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
// Enqueue and process with throttling to avoid relay rate limits
readQueue.append(QueuedRead(receipt: receipt, peerID: peerID))
processReadQueueIfNeeded()
}
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))", category: .session)
// Convert recipient npub -> hex
let recipientHex: String
do {
let (hrp, data) = try Bech32.decode(recipientNpub)
guard hrp == "npub" else { return }
recipientHex = data.hexEncodedString()
} catch {
SecureLogger.error("NostrTransport: failed to decode recipient npub for favorite notification: \(error.localizedDescription)", category: .session)
return
}
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
return
}
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.error("NostrTransport: failed to build Nostr event for favorite notification", category: .session)
return
}
SecureLogger.debug("NostrTransport: sending favorite giftWrap id=\(event.id.prefix(16))", category: .session)
NostrRelayManager.shared.sendEvent(event)
}
}
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
SecureLogger.debug("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))", category: .session)
let recipientHex: String
do {
let (hrp, data) = try Bech32.decode(recipientNpub)
guard hrp == "npub" else { return }
recipientHex = data.hexEncodedString()
} catch {
SecureLogger.error("NostrTransport: failed to decode recipient npub for delivery ack: \(error.localizedDescription)", category: .session)
return
}
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
return
}
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.error("NostrTransport: failed to build Nostr event for DELIVERED ack", category: .session)
return
}
SecureLogger.debug("NostrTransport: sending DELIVERED ack giftWrap id=\(event.id.prefix(16))", category: .session)
NostrRelayManager.shared.sendEvent(event)
}
}
}
// MARK: - Geohash Helpers
extension NostrTransport {
// MARK: Geohash ACK helpers
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
Task { @MainActor in
SecureLogger.debug("GeoDM: send DELIVERED -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
NostrRelayManager.registerPendingGiftWrap(id: event.id)
NostrRelayManager.shared.sendEvent(event)
}
}
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
Task { @MainActor in
SecureLogger.debug("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
NostrRelayManager.registerPendingGiftWrap(id: event.id)
NostrRelayManager.shared.sendEvent(event)
}
}
// MARK: Geohash DMs (per-geohash identity)
func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
Task { @MainActor in
guard !recipientHex.isEmpty else { return }
SecureLogger.debug("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))", category: .session)
// Build embedded BitChat packet without recipient peer ID
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else {
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
return
}
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else {
SecureLogger.error("NostrTransport: failed to build Nostr event for geohash PM", category: .session)
return
}
SecureLogger.debug("NostrTransport: sending geohash PM giftWrap id=\(event.id.prefix(16))", category: .session)
NostrRelayManager.registerPendingGiftWrap(id: event.id)
NostrRelayManager.shared.sendEvent(event)
}
}
}
// MARK: - Private Helpers
extension NostrTransport {
private func processReadQueueIfNeeded() {
guard !isSendingReadAcks else { return }
guard !readQueue.isEmpty else { return }
@@ -98,26 +272,28 @@ final class NostrTransport: Transport {
let item = readQueue.removeFirst()
Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return }
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
SecureLogger.log("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))",
category: SecureLogger.session, level: .debug)
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
SecureLogger.debug("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))", category: .session)
// Convert recipient npub -> hex
let recipientHex: String
do {
let (hrp, data) = try Bech32.decode(recipientNpub)
guard hrp == "npub" else { scheduleNextReadAck(); return }
recipientHex = data.hexEncodedString()
} catch { scheduleNextReadAck(); return }
} catch {
SecureLogger.error("NostrTransport: failed to decode recipient npub for read ack: \(error.localizedDescription)", category: .session)
scheduleNextReadAck()
return
}
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else {
SecureLogger.log("NostrTransport: failed to embed READ ack", category: SecureLogger.session, level: .error)
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
scheduleNextReadAck(); return
}
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.log("NostrTransport: failed to build Nostr event for READ ack", category: SecureLogger.session, level: .error)
SecureLogger.error("NostrTransport: failed to build Nostr event for READ ack", category: .session)
scheduleNextReadAck(); return
}
SecureLogger.log("NostrTransport: sending READ ack giftWrap id=\(event.id.prefix(16))",
category: SecureLogger.session, level: .debug)
SecureLogger.debug("NostrTransport: sending READ ack giftWrap id=\(event.id.prefix(16))", category: .session)
NostrRelayManager.shared.sendEvent(event)
scheduleNextReadAck()
}
@@ -131,119 +307,18 @@ final class NostrTransport: Transport {
}
}
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
SecureLogger.log("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))",
category: SecureLogger.session, level: .debug)
// Convert recipient npub -> hex
let recipientHex: String
do {
let (hrp, data) = try Bech32.decode(recipientNpub)
guard hrp == "npub" else { return }
recipientHex = data.hexEncodedString()
} catch { return }
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
SecureLogger.log("NostrTransport: failed to embed favorite notification", category: SecureLogger.session, level: .error)
return
}
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.log("NostrTransport: failed to build Nostr event for favorite notification", category: SecureLogger.session, level: .error)
return
}
SecureLogger.log("NostrTransport: sending favorite giftWrap id=\(event.id.prefix(16))",
category: SecureLogger.session, level: .debug)
NostrRelayManager.shared.sendEvent(event)
}
}
// MARK: - Helpers
@MainActor
private func resolveRecipientNpub(for peerID: String) -> String? {
if let noiseKey = Data(hexString: peerID),
private func resolveRecipientNpub(for peerID: PeerID) -> String? {
if let noiseKey = Data(hexString: peerID.id),
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
let npub = fav.peerNostrPublicKey {
return npub
}
if peerID.count == 16,
if peerID.id.count == 16,
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
let npub = fav.peerNostrPublicKey {
return npub
}
return nil
}
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
func sendDeliveryAck(for messageID: String, to peerID: String) {
Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
SecureLogger.log("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))",
category: SecureLogger.session, level: .debug)
let recipientHex: String
do {
let (hrp, data) = try Bech32.decode(recipientNpub)
guard hrp == "npub" else { return }
recipientHex = data.hexEncodedString()
} catch { return }
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
SecureLogger.log("NostrTransport: failed to embed DELIVERED ack", category: SecureLogger.session, level: .error)
return
}
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.log("NostrTransport: failed to build Nostr event for DELIVERED ack", category: SecureLogger.session, level: .error)
return
}
SecureLogger.log("NostrTransport: sending DELIVERED ack giftWrap id=\(event.id.prefix(16))",
category: SecureLogger.session, level: .debug)
NostrRelayManager.shared.sendEvent(event)
}
}
// MARK: - Geohash ACK helpers
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
Task { @MainActor in
SecureLogger.log("GeoDM: send DELIVERED -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))",
category: SecureLogger.session, level: .debug)
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
NostrRelayManager.registerPendingGiftWrap(id: event.id)
NostrRelayManager.shared.sendEvent(event)
}
}
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
Task { @MainActor in
SecureLogger.log("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))",
category: SecureLogger.session, level: .debug)
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
NostrRelayManager.registerPendingGiftWrap(id: event.id)
NostrRelayManager.shared.sendEvent(event)
}
}
// MARK: - Geohash DMs (per-geohash identity)
func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
Task { @MainActor in
guard !recipientHex.isEmpty else { return }
SecureLogger.log("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))",
category: SecureLogger.session, level: .debug)
// Build embedded BitChat packet without recipient peer ID
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else {
SecureLogger.log("NostrTransport: failed to embed geohash PM packet", category: SecureLogger.session, level: .error)
return
}
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else {
SecureLogger.log("NostrTransport: failed to build Nostr event for geohash PM", category: SecureLogger.session, level: .error)
return
}
SecureLogger.log("NostrTransport: sending geohash PM giftWrap id=\(event.id.prefix(16))",
category: SecureLogger.session, level: .debug)
NostrRelayManager.registerPendingGiftWrap(id: event.id)
NostrRelayManager.shared.sendEvent(event)
}
}
}
+48 -67
View File
@@ -14,12 +14,23 @@ import UIKit
import AppKit
#endif
class NotificationService {
final class NotificationService {
static let shared = NotificationService()
/// Returns true if running in test environment (XCTest, Swift Testing, or CI)
private var isRunningTests: Bool {
let env = ProcessInfo.processInfo.environment
return NSClassFromString("XCTestCase") != nil ||
env["XCTestConfigurationFilePath"] != nil ||
env["XCTestBundlePath"] != nil ||
env["GITHUB_ACTIONS"] != nil ||
env["CI"] != nil
}
private init() {}
func requestAuthorization() {
guard !isRunningTests else { return }
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
if granted {
// Permission granted
@@ -29,28 +40,31 @@ class NotificationService {
}
}
func sendLocalNotification(title: String, body: String, identifier: String, userInfo: [String: Any]? = nil) {
// For now, skip app state check entirely to avoid thread issues
// The NotificationDelegate will handle foreground presentation
DispatchQueue.main.async {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
if let userInfo = userInfo {
content.userInfo = userInfo
}
let request = UNNotificationRequest(
identifier: identifier,
content: content,
trigger: nil // Deliver immediately
)
UNUserNotificationCenter.current().add(request) { _ in
// Notification added
}
func sendLocalNotification(
title: String,
body: String,
identifier: String,
userInfo: [String: Any]? = nil,
interruptionLevel: UNNotificationInterruptionLevel = .active
) {
guard !isRunningTests else { return }
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
content.interruptionLevel = interruptionLevel
if let userInfo = userInfo {
content.userInfo = userInfo
}
let request = UNNotificationRequest(
identifier: identifier,
content: content,
trigger: nil // Deliver immediately
)
UNUserNotificationCenter.current().add(request)
}
func sendMentionNotification(from sender: String, message: String) {
@@ -61,35 +75,15 @@ class NotificationService {
sendLocalNotification(title: title, body: body, identifier: identifier)
}
func sendPrivateMessageNotification(from sender: String, message: String, peerID: String) {
let title = "🔒 private message from \(sender)"
func sendPrivateMessageNotification(from sender: String, message: String, peerID: PeerID) {
let title = "🔒 DM from \(sender)"
let body = message
let identifier = "private-\(UUID().uuidString)"
let userInfo = ["peerID": peerID, "senderName": sender]
let userInfo = ["peerID": peerID.id, "senderName": sender]
sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo)
}
func sendFavoriteOnlineNotification(nickname: String) {
// Send directly without checking app state for favorites
DispatchQueue.main.async {
let content = UNMutableNotificationContent()
content.title = "\(nickname) is online!"
content.body = "wanna get in there?"
content.sound = .default
let request = UNNotificationRequest(
identifier: "favorite-online-\(UUID().uuidString)",
content: content,
trigger: nil
)
UNUserNotificationCenter.current().add(request) { _ in
// Notification added
}
}
}
// Geohash public chat notification with deep link to a specific geohash
func sendGeohashActivityNotification(geohash: String, titlePrefix: String = "#", bodyPreview: String) {
let title = "\(titlePrefix)\(geohash)"
@@ -103,25 +97,12 @@ class NotificationService {
let title = "👥 bitchatters nearby!"
let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around"
let identifier = "network-available-\(Date().timeIntervalSince1970)"
// For network notifications, we want to show them even in foreground
// No app state check - let the notification delegate handle presentation
DispatchQueue.main.async {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
content.interruptionLevel = .timeSensitive // Make it more prominent
let request = UNNotificationRequest(
identifier: identifier,
content: content,
trigger: nil // Deliver immediately
)
UNUserNotificationCenter.current().add(request) { _ in
// Notification added
}
}
sendLocalNotification(
title: title,
body: body,
identifier: identifier,
interruptionLevel: .timeSensitive
)
}
}
@@ -0,0 +1,133 @@
//
// NotificationStreamAssembler.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitLogger
import Foundation
struct NotificationStreamAssembler {
private var buffer = Data()
private var pendingFrameStartedAt: DispatchTime?
private var pendingFrameExpectedLength: Int = 0
private mutating func resetState() {
buffer.removeAll(keepingCapacity: false)
pendingFrameStartedAt = nil
pendingFrameExpectedLength = 0
}
mutating func append(_ chunk: Data) -> (frames: [Data], droppedPrefixes: [UInt8], reset: Bool) {
guard !chunk.isEmpty else { return ([], [], false) }
buffer.append(chunk)
var frames: [Data] = []
var dropped: [UInt8] = []
var didReset = false
let now = DispatchTime.now()
let maxFrameLength = TransportConfig.bleNotificationAssemblerHardCapBytes
let minimumFramePrefix = BinaryProtocol.v1HeaderSize + BinaryProtocol.senderIDSize
if buffer.count > TransportConfig.bleNotificationAssemblerHardCapBytes {
SecureLogger.error("❌ Notification assembler overflow (\(buffer.count) bytes); dropping partial frame", category: .session)
resetState()
return ([], [], true)
}
while buffer.count >= minimumFramePrefix {
guard let version = buffer.first else { break }
guard version == 1 || version == 2 else {
dropped.append(buffer.removeFirst())
pendingFrameStartedAt = nil
pendingFrameExpectedLength = 0
continue
}
guard let headerSize = BinaryProtocol.headerSize(for: version) else {
dropped.append(buffer.removeFirst())
pendingFrameStartedAt = nil
pendingFrameExpectedLength = 0
continue
}
let framePrefix = headerSize + BinaryProtocol.senderIDSize
guard buffer.count >= framePrefix else { break }
let flagsIndex = buffer.startIndex + BinaryProtocol.Offsets.flags
guard flagsIndex < buffer.endIndex else { break }
let flags = buffer[flagsIndex]
let hasRecipient = (flags & BinaryProtocol.Flags.hasRecipient) != 0
let hasSignature = (flags & BinaryProtocol.Flags.hasSignature) != 0
let isCompressed = (flags & BinaryProtocol.Flags.isCompressed) != 0
let lengthOffset = 12
let payloadLength: Int
if version == 2 {
let lengthIndex = buffer.startIndex + lengthOffset
payloadLength =
(Int(buffer[lengthIndex]) << 24) |
(Int(buffer[lengthIndex + 1]) << 16) |
(Int(buffer[lengthIndex + 2]) << 8) |
Int(buffer[lengthIndex + 3])
} else {
let lengthIndex = buffer.startIndex + lengthOffset
payloadLength = (Int(buffer[lengthIndex]) << 8) | Int(buffer[lengthIndex + 1])
}
var frameLength = framePrefix + payloadLength
if hasRecipient { frameLength += BinaryProtocol.recipientIDSize }
if hasSignature { frameLength += BinaryProtocol.signatureSize }
if isCompressed {
let rawLengthFieldBytes = (version == 2) ? 4 : 2
if payloadLength < rawLengthFieldBytes {
SecureLogger.error("❌ Invalid compressed payload length (\(payloadLength))", category: .session)
resetState()
didReset = true
break
}
}
guard frameLength > 0, frameLength <= maxFrameLength else {
SecureLogger.error("❌ Notification frame length \(frameLength) invalid (cap=\(maxFrameLength)); resetting stream", category: .session)
resetState()
didReset = true
break
}
if buffer.count < frameLength {
let remaining = frameLength - buffer.count
if pendingFrameStartedAt == nil || frameLength != pendingFrameExpectedLength {
pendingFrameStartedAt = now
pendingFrameExpectedLength = frameLength
} else if let started = pendingFrameStartedAt {
let elapsed = now.uptimeNanoseconds - started.uptimeNanoseconds
let threshold = UInt64(TransportConfig.bleAssemblerStallResetMs) * 1_000_000
if elapsed >= threshold {
SecureLogger.debug("📉 Resetting notification assembler after waiting \(remaining)B for \(TransportConfig.bleAssemblerStallResetMs)ms", category: .session)
resetState()
didReset = true
} else {
SecureLogger.debug("⌛ Waiting for remaining \(remaining)B to complete BLE frame", category: .session)
}
}
break
}
pendingFrameStartedAt = nil
pendingFrameExpectedLength = 0
let frame = Data(buffer.prefix(frameLength))
frames.append(frame)
buffer.removeFirst(frameLength)
}
if !buffer.isEmpty, buffer.allSatisfy({ $0 == 0 }) {
resetState()
}
return (frames, dropped, didReset)
}
}

Some files were not shown because too many files have changed in this diff Show More