Compare commits

...
Author SHA1 Message Date
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
jack 3e5043f875 Harden text formatting against Unicode range mismatches
- Use NSString.length for all NSRange regex scans
- Guard string slicing when lastEnd > match.lowerBound
- Advance lastEnd safely to avoid backtracking overlaps
- Apply to formatMessageContent, formatMessage, formatMessageAsText, parseMentions
2025-08-28 09:22:34 +01:00
jack 11cd5527ed Fix crash in formatMessageAsText: use NSString length for NSRanges and guard slicing before matches to avoid invalid ranges with multi-byte content 2025-08-28 09:18:11 +01:00
cf1bfdac6b Geohash Bookmarks + Rising‑Edge Notifications + Friendly Names (#538)
* Add geohash bookmarks: persistence, sampling integration, and UI\n\n- Add GeohashBookmarksStore with UserDefaults persistence and toggle API\n- Sample union of regional + bookmarked geohashes for activity notifications\n- LocationChannelsSheet: bookmark icons on nearby rows and a Bookmarked section\n- Header toolbar: toggle bookmark for current geohash\n- Tests: GeohashBookmarksStoreTests for normalization and persistence\n\nRationale: Bookmarked geohashes are always sampled for new activity notifications and quickly selectable from the sheet.

* Notifications: remove background 'new chats!' nudge; geohash activity only on zero→alive; mesh 'nearby' only on zero→alive\n\n- Geohash sampling: notify only when previous participant count in last 5m was zero, with 30s freshness gate\n- Suppress old sampled events after (re)subscribe\n- Remove channel inactivity nudge\n- Mesh: reset rising-edge gate immediately when peer list goes empty (strict zero→alive)

* Geohash notifications: ensure triggering message appears\n\n- On zero→alive sampling, pre-populate geoTimelines[gh] with the triggering message\n- Dedup on per-geohash timeline and UI messages by message ID to avoid duplicates after subscribe\n- Keeps participant update, block/self checks, and cooldown intact

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-28 09:10:15 +01:00
b63a595b04 Fix geohash UX: rising-edge notify, correct teleport, sort timeline (#537)
- Remove "new chats!" background nudge; notify only when a regional geohash goes from 0 participants to activity, with content preview and cooldown.\n- Do not mark teleported when selected geohash is in regional channel list; clear persisted teleport when in-region; avoid self teleported state from historical tags; deep links don’t mark teleported for regional geohashes or during cold start.\n- Sort per-geohash timelines chronologically on re-entry and trim whitespace-only content to prevent blank lines.\n- Trim inbound public content and ignore whitespace-only sends.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-28 00:08:01 +01:00
d7b7f1f673 UI: diversify peer colors; smarter geo notifications; 21m live location (#531)
- Use minimal-distance hue palette for mesh and geohash lists; align chat sender colors with list palette.
- Add foreground geo notifications for different channels; deep-link to bitchat://geohash/<gh>; per-geohash 60s cooldown; respect self/blocks.
- Global geohash sampling runs outside the sheet; delegate handles deeplinks and suppresses when already in-channel.
- Switch location channel sheet to continuous CoreLocation with 21m distance filter; add config knob.
- Only link geohash hashtags when standalone (no @name#abcd or word#abcd).
- Include mesh-reachable peers in mesh counts and in "bitchatters nearby" notification.
- Add TransportConfig knobs for palette and geo notifications.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-26 18:43:22 +02:00
JDandGitHub 7aa3622349 Update README to explain hybrid messaging architecture (#530) 2025-08-26 18:42:21 +02:00
96c6fc0c0d Improve mesh relaying, presence, and DM robustness; signed public msgs; reachability UI; quicker announces; store-and-forward (#527)
* Sign public broadcasts; verify relayed messages via persisted signing keys; keep scheduled relays in sparse graphs and speed their jitter; persist announce signing key for offline auth; add short backoff after disconnect errors to reduce reconnect thrash

* Add connected vs reachable model: retain peers after link drop, expire after reachability window; expose all peers in snapshots; compute isReachable in UI; add meshReachable state and sorting; avoid removing peers on link events; notify UI on stale removals

* ContentView: handle new .meshReachable connection state in header icon switch (exhaustive switch fix)

* Logs: tag relayed announces as 'Reachable via mesh' and annotate public message logs with (direct|mesh) path for easier field analysis

* Fix syntax error: remove stray else/log inserted into writeOrEnqueue; keep logs clean

* UI: use 'point.3.connected.trianglepath.dotted' for mesh-reachable; change people count to include connected+reachable (exclude Nostr-only)

* UI: switch to 'point.3.filled.connected.trianglepath.dotted' for mesh-reachable icons in list and header

* Reachability: reduce retention to 21s for all peers (verified and unverified) to minimize stale presence

* mesh DMs/acks: route to reachable peers; queue READ/DELIVERED until handshake; add Transport.isPeerReachable; UI: hide offline non-mutuals; DM header: better name fallback + show transport + encryption icons; fix NostrTransport conformance

* Verification sheet: compute encryption status and fingerprint using short mesh ID mapping (fix 'not encrypted/handshake' for DMs with stable key)

* Announce cadence: faster discovery (4s), sparse 15±4s, dense 30±8s; initial 0.6s; post-subscribe 50ms; min-force 150ms; maintenance 5s; proactive announces on handshake + recent-traffic nudge

* Relay: increase broadcast TTL cap in sparse graphs to 6; tighten jitter for handshake (10–35ms) and directed (20–60ms) relays

* Range/robustness: store-and-forward for directed packets (15s) with flush on new links + periodic; announces: no subset + afterglow re-announce on first-seen; adaptive scanning: force ON when <=2 neighbors or recent traffic

* Fix warnings: remove unused msgID and unused mutable var in directed spool flush

* Announces: TTL 7 (sparse only) via RelayController; no fanout subset for announces; neighbor-change rebroadcast of last 2–3 announces. Fragments: faster pacing (5ms global, 4ms directed).

* Peer list: real-time icon updates by publishing snapshots on connectivity checks; add unread message indicator (envelope) next to peers with unread DMs

* UI: unread envelope uses orange; hasUnreadMessages checks Nostr conv key for peers with known Nostr pubkeys (geohash DM consistency)

* Logs/robustness: debounce disconnect notifications (1.5s), debounce 'reconnected' logs (2s), add weak-link cooldown after timeouts on very weak RSSI (<= -90)

* Peer icons: faster, accurate reachability\n\n- Run connectivity checks every maintenance tick (5s)\n- Publish peer snapshots on central unsubscribe for instant UI refresh\n- Lower inactivity timeout to 8s and disconnect debounce to 0.9s\n- Gate reachability on mesh-attached (>=1 direct link); no links => no reachable peers\n- Keep 21s retention for verified/unverified, but only when attached to mesh\n\nImproves list responsiveness when walking out of range and prevents stale 'reachable' states when isolated.

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-26 17:28:08 +02:00
a7d5b2d7d9 Centralize remaining magic numbers; finalize logging hygiene across UI/BLE/Nostr (#522)
* docs(plans): add refactor plan; chore(config): introduce TransportConfig and use in BLEService/ChatViewModel/PrivateChatManager; feat: add PeerDisplayNameResolver and apply in BLEService; chore: make TransportPeerSnapshot Equatable/Hashable; perf: simplify read-receipt persistence (no synchronize)

* project: add TransportConfig.swift and PeerDisplayNameResolver.swift to iOS/macOS targets (no xcodegen)

* chore: remove unnecessary UserDefaults.synchronize() calls (extension/app/VM)

* project: fix TransportConfig reference path; remove recovered reference; hook correct fileRef in iOS/macOS sources

* chore(BLE): move connect/duty/announce constants to TransportConfig and reference them

* chore(NostrTransport): factor recipient npub resolution into helper; reduce duplication

* chore(BLE): trim raw hex dump on central decode failure to length+prefix

* project: remove duplicate TransportConfig.swift entries from Sources build phases

* chore: centralize more constants in TransportConfig (BLE thresholds, Nostr read-ack, UI caps) and adopt in BLEService/ChatViewModel/NostrTransport

* chore: centralize location + geohash constants (filters, lookback, relay count) and adopt in LocationChannelManager/ChatViewModel

* chore: centralize compression, dedup, verification QR, relay backoff, georelay fetch constants; adopt across modules

* chore: centralize more BLE/Nostr delays; tighten NostrRelayManager logs to concise summaries; adopt config for location/geohash/relays

* refactor(config): centralize remaining magic numbers and finalize log hygiene

Add comprehensive TransportConfig constants for UI, Nostr, and BLE; adopt across ChatViewModel, ContentView, BLEService, ShareViewController, and BitchatApp to remove scattered literals. Standardize Nostr lookbacks/limits, UI delays/animations, and BLE announce/duty-cycle/candidate caps. Preserve behavior while making tuning explicit and safe.

Highlights:\n- UI: animations, scroll throttle, long-message thresholds, batch stagger, color hue tuning, rate-limit buckets, read-receipt debounce, startup delays, share accept/dismiss windows, migration cutoff.\n- Nostr: short display length (8), conv-key prefix length (16), DM lookback (24h), geohash sample lookback/limits; consistent use throughout ChatViewModel.\n- BLE: dynamic RSSI defaults, announce intervals/base+jitter, duty cycles (dense/sparse), fragment/ingress lifetimes, expected write timings/spacing, recent packet window (30s/100), peer inactivity timeout; unified candidate caps (100).\n- Share: use constant dismiss delay; App: use constant share accept window.\n\nRisk/impact: behavior-equivalent with centralized knobs; easier to tune without code edits.

* project: add TransportConfig.swift to Share Extension target to fix build

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-25 21:18:51 +02:00
680a390b2d Nostr: sign events directly with Schnorr keys; update call sites to use Schnorr and remove temporary Signing.PrivateKey conversions (#521)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-25 18:31:53 +02:00
60b0deee7b Cleanup: remove dead code, normalize fingerprints, modernize share extension, trim test noise, and drop ‘preparing to share…’ message (#520)
* Remove dead code and artifacts: drop PeerManager, unused views/types; delete LegacyTestProtocolTypes; update .gitignore; purge TestResult.xcresult and build.log

* Tests: gate verbose prints under DEBUG; ChatViewModel: remove legacy fingerprint helper and rely on UnifiedPeerService

* Share Extension: migrate to UIKit + UTTypes; drop Social/SLComposeServiceViewController

* Remove 'preparing to share …' system message; send shared content immediately

* Inline comment cleanup: drop legacy 'removed' breadcrumbs across protocols, services, view model, and views

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-25 18:01:19 +02:00
jackandGitHub 2f7c0aaaf7 Delete TestResult.xcresult directory 2025-08-25 16:36:36 +02:00
7c4c3f1391 macOS geohash parity: shared LocationChannelsSheet with permission CTA, enable CoreLocation on macOS, unify geohash participants/DMs, update ContentView (unread + QR on macOS), commands: hide/block /fav & /unfav in geohash, remove /help, make /who show geohash participants, fix ViewBuilder mutation+sheet toolbar, entitlements for mac location (#519)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-25 16:29:52 +02:00
3c06bd6386 Improve BLE mesh flooding: last-hop suppression, K-of-N fanout, and backpressure (#517)
* Improve BLE mesh relay and flooding

Add last-hop suppression using ingress-link tracking to prevent echo. Implement deterministic always-relay for handshakes and directed encrypted/fragments; widen jitter for broadcasts; keep TTL cap only for broadcast. Add deterministic K-of-N broadcast fanout to reduce amplification in dense topologies. Introduce backpressure-aware writes using canSendWriteWithoutResponse with per-peripheral queues and draining on peripheralIsReady. Minor helpers for messageID, deterministic selection, and maintenance cleanup.

* Tests: stabilize FragmentationTests and InputValidatorTests

Make _test_handlePacket mark synthetic peers verified/connected with normalized senderID to avoid drops in public-message reassembly tests. Tighten validatePeerID to reject non-hex strings when length equals 16 or 64; allow internal IDs only for other lengths. All iOS simulator tests pass locally.

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-25 11:51:50 +02:00
jack acc11101ad Fix geohash linking: avoid links in @mentions
Only link #geohash tokens when not directly attached to an @mention (e.g., @name#gh stays plain). Keeps standalone #geohash tappable.
2025-08-25 09:31:41 +02:00
5fd9140ffa QR verification: live challenge/response over Noise; persistence, offline badges, and UX/perf polish (#510)
* QR verification scaffold: add Noise verify payload types, VerificationService with QR schema/signing, placeholder MyQR/Scan views, and UI entry points in header

* QR: fix VerificationQR mutability (sigHex var) and remove duplicate Data hex helpers to resolve redeclaration; wire signed payload assembly

* QR: render actual QR images with CoreImage; add copy button; keep scanner placeholder for now

* QR: fix SwiftUI modifiers — apply .interpolation(.none) and .resizable() to platform Image inside ImageWrapper; remove from wrapper usage

* QR: add iOS camera scanner using AVFoundation; integrate into Scan view; add NSCameraUsageDescription to Info.plist

* QR: make NoisePayloadType exhaustive in ChatViewModel switches by ignoring verifyChallenge/verifyResponse for now (placeholder)

* QR verification: speed + persistence + UX

- Inject live Noise into VerificationService; prewarm QR on app start
- Keep camera active; remove intermediate responder toast
- One-shot/dupe guards and deferred send on handshake
- Persist verified status immediately; standardize fingerprint (SHA-256)
- Show verified badge for offline favorites; mutual verification toast
- VERIFY sheet styling to match peer sheet; UI polish
- Logs to diagnose verified load + favorites mapping

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-24 23:19:58 +02:00
2427 changed files with 409047 additions and 11697 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
+40
View File
@@ -0,0 +1,40 @@
name: Fetch GeoRelays Data
on:
schedule:
- cron: '0 6 * * 0'
workflow_dispatch:
permissions:
contents: write
jobs:
update-relay-data:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Fetch GeoRelays
run: |
wget https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
mv nostr_relays.csv ./relays/online_relays_gps.csv
- name: Check for changes
id: git-check
run: |
git diff --exit-code || echo "changes=true" >> $GITHUB_OUTPUT
- name: Commit and push changes
if: steps.git-check.outputs.changes == 'true'
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add relays/online_relays_gps.csv
git commit -m "Automated update of relay data - $(date -u)"
git push
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+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
+12 -4
View File
@@ -9,9 +9,6 @@ plans/
CLAUDE.md CLAUDE.md
AGENTS.md AGENTS.md
## User settings
xcuserdata/
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
*.xcscmblueprint *.xcscmblueprint
*.xccheckout *.xccheckout
@@ -57,7 +54,8 @@ iOSInjectionProject/
## Xcode project ## Xcode project
*.xcodeproj/project.xcworkspace/ *.xcodeproj/project.xcworkspace/
*.xcodeproj/xcshareddata/ ## Xcode User settings
xcuserdata/
## Python ## Python
__pycache__/ __pycache__/
@@ -68,6 +66,16 @@ __pycache__/
*.tmp *.tmp
*.temp *.temp
## Cache
.cache/
# Local build results # Local build results
.Result*/ .Result*/
.Result*.xcresult/ .Result*.xcresult/
TestResult.xcresult/
*.xcresult/
build.log
*.log
# Local configs
Local.xcconfig
+3 -3
View File
@@ -37,7 +37,7 @@ This three-message pattern provides:
#### NoiseEncryptionService #### NoiseEncryptionService
The main service managing all Noise operations: The main service managing all Noise operations:
```swift ```swift
class NoiseEncryptionService { final class NoiseEncryptionService {
private let staticIdentityKey: Curve25519.KeyAgreement.PrivateKey private let staticIdentityKey: Curve25519.KeyAgreement.PrivateKey
private let sessionManager: NoiseSessionManager private let sessionManager: NoiseSessionManager
private let channelEncryption = NoiseChannelEncryption() private let channelEncryption = NoiseChannelEncryption()
@@ -47,7 +47,7 @@ class NoiseEncryptionService {
#### NoiseSession #### NoiseSession
Individual session state for each peer: Individual session state for each peer:
```swift ```swift
class NoiseSession { final class NoiseSession {
private var handshakeState: NoiseHandshakeState? private var handshakeState: NoiseHandshakeState?
private var sendCipher: NoiseCipherState? private var sendCipher: NoiseCipherState?
private var receiveCipher: NoiseCipherState? private var receiveCipher: NoiseCipherState?
@@ -58,7 +58,7 @@ class NoiseSession {
#### NoiseSessionManager #### NoiseSessionManager
Thread-safe session management: Thread-safe session management:
```swift ```swift
class NoiseSessionManager { final class NoiseSessionManager {
private var sessions: [String: NoiseSession] = [:] private var sessions: [String: NoiseSession] = [:]
private let sessionsQueue = DispatchQueue(label: "noise.sessions", attributes: .concurrent) private let sessionsQueue = DispatchQueue(label: "noise.sessions", attributes: .concurrent)
} }
+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.4.4
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
+9 -18
View File
@@ -14,16 +14,16 @@ default:
# Check prerequisites # Check prerequisites
check: check:
@echo "Checking prerequisites..." @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 "❌ xcodebuild not found. Install Xcode from App Store" && exit 1)
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ Xcode 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)
@security find-identity -v -p codesigning | grep -q "Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0) @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" @echo "✅ All prerequisites met"
# Backup original files # Backup original files
backup: backup:
@echo "Backing up original project configuration..." @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.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 @if [ -f bitchat/Info.plist ]; then cp bitchat/Info.plist bitchat/Info.plist.backup; fi
@@ -44,15 +44,10 @@ patch-for-macos: backup
@# Move iOS-specific files out of the way temporarily @# Move iOS-specific files out of the way temporarily
@if [ -f bitchat/LaunchScreen.storyboard ]; then mv bitchat/LaunchScreen.storyboard bitchat/LaunchScreen.storyboard.ios; fi @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 the macOS app
build: check generate build: #check generate
@echo "Building BitChat for macOS..." @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 @xcodebuild -project bitchat.xcodeproj -scheme "bitchat_macOS" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
# Run the macOS app # Run the macOS app
run: build run: build
@@ -75,9 +70,7 @@ clean: restore
# Quick run without cleaning (for development) # Quick run without cleaning (for development)
dev-run: check dev-run: check
@echo "Quick development build..." @echo "Quick development build..."
@if [ ! -f project.yml.backup ]; then just patch-for-macos; fi @xcodebuild -project bitchat.xcodeproj -scheme "bitchat_macOS" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
@xcodegen generate
@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 "{}" @find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" -not -path "*/Index.noindex/*" | head -1 | xargs -I {} open "{}"
# Show app info # Show app info
@@ -106,11 +99,9 @@ nuke:
@echo "🧨 Nuclear clean - removing all build artifacts and backups..." @echo "🧨 Nuclear clean - removing all build artifacts and backups..."
@rm -rf ~/Library/Developer/Xcode/DerivedData/bitchat-* 2>/dev/null || true @rm -rf ~/Library/Developer/Xcode/DerivedData/bitchat-* 2>/dev/null || true
@rm -rf bitchat.xcodeproj 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.xcodeproj/project.pbxproj.backup 2>/dev/null || true
@rm -f bitchat/Info.plist.backup 2>/dev/null || true @rm -f bitchat/Info.plist.backup 2>/dev/null || true
@# Restore iOS-specific files if they were moved @# Restore iOS-specific files if they were moved
@if [ -f bitchat/LaunchScreen.storyboard.ios ]; then mv bitchat/LaunchScreen.storyboard.ios bitchat/LaunchScreen.storyboard; fi @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" @echo "✅ Nuclear clean complete"
+22 -2
View File
@@ -4,6 +4,7 @@ import PackageDescription
let package = Package( let package = Package(
name: "bitchat", name: "bitchat",
defaultLocalization: "en",
platforms: [ platforms: [
.iOS(.v16), .iOS(.v16),
.macOS(.v13) .macOS(.v13)
@@ -15,13 +16,17 @@ let package = Package(
), ),
], ],
dependencies:[ 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: [ targets: [
.executableTarget( .executableTarget(
name: "bitchat", name: "bitchat",
dependencies: [ 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", path: "bitchat",
exclude: [ exclude: [
@@ -30,7 +35,22 @@ let package = Package(
"bitchat.entitlements", "bitchat.entitlements",
"bitchat-macOS.entitlements", "bitchat-macOS.entitlements",
"LaunchScreen.storyboard" "LaunchScreen.storyboard"
],
resources: [
.process("Localizable.xcstrings")
] ]
), ),
.testTarget(
name: "bitchatTests",
dependencies: ["bitchat"],
path: "bitchatTests",
exclude: [
"Info.plist",
"README.md"
],
resources: [
.process("Localization")
]
)
] ]
) )
+82 -45
View File
@@ -2,87 +2,124 @@
## bitchat ## bitchat
A decentralized peer-to-peer messaging app that works over Bluetooth mesh networks. No internet required, no servers, no phone numbers. It's the side-groupchat. A decentralized peer-to-peer messaging app with dual transport architecture: local Bluetooth mesh networks for offline communication and internet-based Nostr protocol for global reach. No accounts, no phone numbers, no central servers. It's the side-groupchat.
[bitchat.free](http://bitchat.free) [bitchat.free](http://bitchat.free)
📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622) 📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622)
> [!WARNING] > [!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 ## License
This project is released into the public domain. See the [LICENSE](LICENSE) file for details. This project is released into the public domain. See the [LICENSE](LICENSE) file for details.
## Features ## Features
- **Dual Transport Architecture**: Bluetooth mesh for offline + Nostr protocol for internet-based messaging
- **Location-Based Channels**: Geographic chat rooms using geohash coordinates over global Nostr relays
- **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback)
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE - **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers - **Privacy First**: No accounts, no phone numbers, no persistent identifiers
- **Private Message End-to-End Encryption**: [Noise Protocol](http://noiseprotocol.org) - **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 - **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface
- **Universal App**: Native support for iOS and macOS - **Universal App**: Native support for iOS and macOS
- **Emergency Wipe**: Triple-tap to instantly clear all data - **Emergency Wipe**: Triple-tap to instantly clear all data
- **Performance Optimizations**: LZ4 message compression, adaptive battery modes, and optimized networking - **Performance Optimizations**: LZ4 message compression, adaptive battery modes, and optimized networking
## [Technical Architecture](https://deepwiki.com/permissionlesstech/bitchat) ## [Technical Architecture](https://deepwiki.com/permissionlesstech/bitchat)
### Binary Protocol BitChat uses a **hybrid messaging architecture** with two complementary transport layers:
bitchat uses an efficient binary protocol optimized for Bluetooth LE:
- Compact packet format with 1-byte type field
- TTL-based message routing (max 7 hops)
- Automatic fragmentation for large messages
- Message deduplication via unique IDs
### Mesh Networking ### Bluetooth Mesh Network (Offline)
- Each device acts as both client and peripheral
- Automatic peer discovery and connection management - **Local Communication**: Direct peer-to-peer within Bluetooth range
- Adaptive duty cycling for battery optimization - **Multi-hop Relay**: Messages route through nearby devices (max 7 hops)
- **No Internet Required**: Works completely offline in disaster scenarios
- **Noise Protocol Encryption**: End-to-end encryption with forward secrecy
- **Binary Protocol**: Compact packet format optimized for Bluetooth LE constraints
- **Automatic Discovery**: Peer discovery and connection management
- **Adaptive Power**: Battery-optimized duty cycling
### Nostr Protocol (Internet)
- **Global Reach**: Connect with users worldwide via internet relays
- **Location Channels**: Geographic chat rooms using geohash coordinates
- **290+ Relay Network**: Distributed across the globe for reliability
- **NIP-17 Encryption**: Gift-wrapped private messages for internet privacy
- **Ephemeral Keys**: Fresh cryptographic identity per geohash area
### Channel Types
#### `mesh #bluetooth`
- **Transport**: Bluetooth Low Energy mesh network
- **Scope**: Local devices within multi-hop range
- **Internet**: Not required
- **Use Case**: Offline communication, protests, disasters, remote areas
#### Location Channels (`block #dr5rsj7`, `neighborhood #dr5rs`, `country #dr`)
- **Transport**: Nostr protocol over internet
- **Scope**: Geographic areas defined by geohash precision
- `block` (7 chars): City block level
- `neighborhood` (6 chars): District/neighborhood
- `city` (5 chars): City level
- `province` (4 chars): State/province
- `region` (2 chars): Country/large region
- **Internet**: Required (connects to Nostr relays)
- **Use Case**: Location-based community chat, local events, regional discussions
### Direct Message Routing
Private messages use **intelligent transport selection**:
1. **Bluetooth First** (preferred when available)
- Direct connection with established Noise session
- Fastest and most private option
2. **Nostr Fallback** (when Bluetooth unavailable)
- Uses recipient's Nostr public key
- NIP-17 gift-wrapping for privacy
- Routes through global relay network
3. **Smart Queuing** (when neither available)
- Messages queued until transport becomes available
- Automatic delivery when connection established
For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.md). For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.md).
## Setup ## Setup
### Option 1: Using XcodeGen (Recommended) ### Option 1: Using Xcode
1. Install XcodeGen if you haven't already:
```bash
brew install xcodegen
```
2. Generate the Xcode project:
```bash ```bash
cd bitchat cd bitchat
xcodegen generate
```
3. Open the generated project:
```bash
open bitchat.xcodeproj 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`)
### Option 2: Using `just`
1. Open the project in Xcode:
```bash ```bash
cd bitchat brew install just
open Package.swift
``` ```
2. Select your target device and run Want to try this on macos: `just run` will set it up and run from source.
### 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. 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. **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`) ### 6.2. Application Message Format (`BitchatMessage`)
For packets of type `message`, the payload is a binary-serialized `BitchatMessage` containing the chat content. 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. | | 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. | | 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 ## 7. Message Routing and Propagation
+258 -706
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 = "-DBITCHAT_DEV_ALLOW_CLEARNET"
value = ""
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" : [ "images" : [
{
"filename" : "icon_20x20@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "20x20"
},
{
"filename" : "icon_20x20@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "20x20"
},
{
"filename" : "icon_29x29@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "29x29"
},
{
"filename" : "icon_29x29@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "29x29"
},
{
"filename" : "icon_40x40@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "40x40"
},
{
"filename" : "icon_40x40@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "40x40"
},
{
"filename" : "icon_60x60@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "60x60"
},
{
"filename" : "icon_60x60@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "60x60"
},
{
"filename" : "icon_20x20.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "20x20"
},
{
"filename" : "icon_20x20@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "20x20"
},
{
"filename" : "icon_29x29.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "29x29"
},
{
"filename" : "icon_29x29@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "29x29"
},
{
"filename" : "icon_40x40.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "40x40"
},
{
"filename" : "icon_40x40@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "40x40"
},
{
"filename" : "icon_76x76.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "76x76"
},
{
"filename" : "icon_76x76@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "76x76"
},
{
"filename" : "icon_83.5x83.5@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "83.5x83.5"
},
{ {
"filename" : "icon_1024x1024.png", "filename" : "icon_1024x1024.png",
"idiom" : "ios-marketing", "idiom" : "universal",
"scale" : "1x", "platform" : "ios",
"size" : "1024x1024" "size" : "1024x1024"
}, },
{ {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 378 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 497 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 570 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 401 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 564 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 668 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 497 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 641 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 765 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 765 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 628 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 930 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 976 B

@@ -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", "author" : "xcode",
"version" : 1 "version" : 1
} }
} }
+87 -23
View File
@@ -6,20 +6,39 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import Tor
import SwiftUI import SwiftUI
import UserNotifications import UserNotifications
@main @main
struct BitchatApp: App { 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) #if os(iOS)
@Environment(\.scenePhase) var scenePhase @Environment(\.scenePhase) var scenePhase
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @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) #elseif os(macOS)
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate @NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
#endif #endif
private let idBridge = NostrIdentityBridge()
init() { init() {
let keychain = KeychainManager()
let idBridge = self.idBridge
_chatViewModel = StateObject(
wrappedValue: ChatViewModel(
keychain: keychain,
idBridge: idBridge,
identityManager: SecureIdentityStateManager(keychain)
)
)
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
// Warm up georelay directory and refresh if stale (once/day) // Warm up georelay directory and refresh if stale (once/day)
GeoRelayDirectory.shared.prefetchIfNeeded() GeoRelayDirectory.shared.prefetchIfNeeded()
@@ -31,11 +50,20 @@ struct BitchatApp: App {
.environmentObject(chatViewModel) .environmentObject(chatViewModel)
.onAppear { .onAppear {
NotificationDelegate.shared.chatViewModel = chatViewModel NotificationDelegate.shared.chatViewModel = chatViewModel
// 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
DispatchQueue.global(qos: .utility).async {
let npub = try? idBridge.getCurrentNostrIdentity()?.npub
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
}
#if os(iOS) #if os(iOS)
appDelegate.chatViewModel = chatViewModel appDelegate.chatViewModel = chatViewModel
#elseif os(macOS) #elseif os(macOS)
appDelegate.chatViewModel = chatViewModel appDelegate.chatViewModel = chatViewModel
#endif #endif
// Initialize network activation policy; will start Tor/Nostr only when allowed
NetworkActivationService.shared.start()
// Check for shared content // Check for shared content
checkForSharedContent() checkForSharedContent()
} }
@@ -47,10 +75,41 @@ struct BitchatApp: App {
switch newPhase { switch newPhase {
case .background: case .background:
// Keep BLE mesh running in background; BLEService adapts scanning automatically // 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: case .active:
// Restart services when becoming active // Restart services when becoming active
chatViewModel.meshService.startServices() 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() checkForSharedContent()
case .inactive: case .inactive:
break break
@@ -83,7 +142,7 @@ struct BitchatApp: App {
private func checkForSharedContent() { private func checkForSharedContent() {
// Check app group for shared content from extension // 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 return
} }
@@ -92,30 +151,18 @@ struct BitchatApp: App {
return return
} }
// Only process if shared within last 30 seconds // Only process if shared within configured window
if Date().timeIntervalSince(sharedDate) < 30 { if Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds {
let contentType = userDefaults.string(forKey: "sharedContentType") ?? "text" let contentType = userDefaults.string(forKey: "sharedContentType") ?? "text"
// Clear the shared content // Clear the shared content
userDefaults.removeObject(forKey: "sharedContent") userDefaults.removeObject(forKey: "sharedContent")
userDefaults.removeObject(forKey: "sharedContentType") userDefaults.removeObject(forKey: "sharedContentType")
userDefaults.removeObject(forKey: "sharedContentDate") userDefaults.removeObject(forKey: "sharedContentDate")
userDefaults.synchronize() // No need to force synchronize here
// Show notification about shared content // Send the shared content immediately on the main queue
DispatchQueue.main.async { DispatchQueue.main.async {
// Add system message about sharing
let systemMessage = BitchatMessage(
sender: "system",
content: "preparing to share \(contentType)...",
timestamp: Date(),
isRelay: false
)
self.chatViewModel.messages.append(systemMessage)
}
// Send the shared content after a short delay
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
if contentType == "url" { if contentType == "url" {
// Try to parse as JSON first // Try to parse as JSON first
if let data = sharedContent.data(using: .utf8), if let data = sharedContent.data(using: .utf8),
@@ -136,7 +183,7 @@ struct BitchatApp: App {
} }
#if os(iOS) #if os(iOS)
class AppDelegate: NSObject, UIApplicationDelegate { final class AppDelegate: NSObject, UIApplicationDelegate {
weak var chatViewModel: ChatViewModel? weak var chatViewModel: ChatViewModel?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
@@ -148,7 +195,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
#if os(macOS) #if os(macOS)
import AppKit import AppKit
class MacAppDelegate: NSObject, NSApplicationDelegate { final class MacAppDelegate: NSObject, NSApplicationDelegate {
weak var chatViewModel: ChatViewModel? weak var chatViewModel: ChatViewModel?
func applicationWillTerminate(_ notification: Notification) { func applicationWillTerminate(_ notification: Notification) {
@@ -161,7 +208,7 @@ class MacAppDelegate: NSObject, NSApplicationDelegate {
} }
#endif #endif
class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate { final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
static let shared = NotificationDelegate() static let shared = NotificationDelegate()
weak var chatViewModel: ChatViewModel? weak var chatViewModel: ChatViewModel?
@@ -174,10 +221,18 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
// Get peer ID from userInfo // Get peer ID from userInfo
if let peerID = userInfo["peerID"] as? String { if let peerID = userInfo["peerID"] as? String {
DispatchQueue.main.async { DispatchQueue.main.async {
self.chatViewModel?.startPrivateChat(with: peerID) self.chatViewModel?.startPrivateChat(with: PeerID(str: peerID))
} }
} }
} }
// Handle deeplink (e.g., geohash activity)
if let deep = userInfo["deeplink"] as? String, let url = URL(string: deep) {
#if os(iOS)
DispatchQueue.main.async { UIApplication.shared.open(url) }
#else
DispatchQueue.main.async { NSWorkspace.shared.open(url) }
#endif
}
completionHandler() completionHandler()
} }
@@ -197,6 +252,15 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
} }
} }
} }
// Suppress geohash activity notification if we're already in that geohash channel
if identifier.hasPrefix("geo-activity-"),
let deep = userInfo["deeplink"] as? String,
let gh = deep.components(separatedBy: "/").last {
if case .location(let ch) = LocationChannelManager.shared.selectedChannel, ch.geohash == gh {
completionHandler([])
return
}
}
// Show notification in all other cases // Show notification in all other cases
completionHandler([.banner, .sound]) completionHandler([.banner, .sound])
+5 -69
View File
@@ -87,7 +87,7 @@ import Foundation
/// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy. /// 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. /// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships.
struct EphemeralIdentity { struct EphemeralIdentity {
let peerID: String // 8 random bytes let peerID: PeerID // 8 random bytes
let sessionStart: Date let sessionStart: Date
var handshakeState: HandshakeState var handshakeState: HandshakeState
} }
@@ -106,6 +106,8 @@ enum HandshakeState {
struct CryptographicIdentity: Codable { struct CryptographicIdentity: Codable {
let fingerprint: String // SHA256 of public key let fingerprint: String // SHA256 of public key
let publicKey: Data // Noise static public key let publicKey: Data // Noise static public key
// Optional Ed25519 signing public key (used to authenticate public messages)
var signingPublicKey: Data? = nil
let firstSeen: Date let firstSeen: Date
let lastHandshake: Date? let lastHandshake: Date?
} }
@@ -156,73 +158,7 @@ struct IdentityCache: Codable {
var version: Int = 1 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: - Privacy Settings
struct PrivacySettings: Codable {
// Level 1: Maximum privacy (default)
var persistIdentityCache = false
var showLastSeen = false
// Level 2: Convenience
var autoAcceptKnownFingerprints = false
var rememberNicknameHistory = false
// Level 3: Social
var shareTrustNetworkHints = false // "3 mutual contacts trust this person"
}
// MARK: - Conflict Resolution
/// Strategies for resolving identity conflicts in the decentralized network.
/// Handles cases where multiple peers claim the same nickname or when
/// identity mappings become ambiguous due to network partitions.
enum ConflictResolution {
case acceptNew(petname: String) // "John (2)"
case rejectNew
case blockFingerprint(String)
case alertUser(message: String)
}
// MARK: - UI State
struct PeerUIState {
let peerID: String
let nickname: String
var identityState: IdentityState
var connectionQuality: ConnectionQuality
enum IdentityState {
case unknown // Gray - No identity info
case unverifiedKnown(String) // Blue - Handshake done, matches cache
case verified(String) // Green - Cryptographically verified
case conflict(String, String) // Red - Nickname doesn't match fingerprint
case pending // Yellow - Handshake in progress
}
}
enum ConnectionQuality {
case excellent
case good
case poor
case disconnected
}
// MARK: - Migration Support // MARK: - Migration Support
// Removed LegacyFavorite - no longer needed //
+143 -99
View File
@@ -90,27 +90,63 @@
/// - Advanced conflict resolution /// - Advanced conflict resolution
/// ///
import BitLogger
import Foundation import Foundation
import CryptoKit import CryptoKit
protocol SecureIdentityStateManagerProtocol {
// MARK: Secure Loading/Saving
func forceSave()
// MARK: Social Identity Management
func getSocialIdentity(for fingerprint: String) -> SocialIdentity?
// MARK: Cryptographic Identities
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?)
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: 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. /// Singleton manager for secure identity state persistence and retrieval.
/// Provides thread-safe access to identity mappings with encryption at rest. /// Provides thread-safe access to identity mappings with encryption at rest.
/// All identity data is stored encrypted in the device Keychain for security. /// All identity data is stored encrypted in the device Keychain for security.
class SecureIdentityStateManager { final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
static let shared = SecureIdentityStateManager() private let keychain: KeychainManagerProtocol
private let keychain = KeychainManager.shared
private let cacheKey = "bitchat.identityCache.v2" private let cacheKey = "bitchat.identityCache.v2"
private let encryptionKeyName = "identityCacheEncryptionKey" private let encryptionKeyName = "identityCacheEncryptionKey"
// In-memory state // In-memory state
private var ephemeralSessions: [String: EphemeralIdentity] = [:] private var ephemeralSessions: [PeerID: EphemeralIdentity] = [:]
private var cryptographicIdentities: [String: CryptographicIdentity] = [:] private var cryptographicIdentities: [String: CryptographicIdentity] = [:]
private var cache: IdentityCache = IdentityCache() private var cache: IdentityCache = IdentityCache()
// Pending actions before handshake
private var pendingActions: [String: PendingActions] = [:]
// Thread safety // Thread safety
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent) private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
@@ -122,14 +158,16 @@ class SecureIdentityStateManager {
// Encryption key // Encryption key
private let encryptionKey: SymmetricKey private let encryptionKey: SymmetricKey
private init() { init(_ keychain: KeychainManagerProtocol) {
self.keychain = keychain
// Generate or retrieve encryption key from keychain // Generate or retrieve encryption key from keychain
let loadedKey: SymmetricKey let loadedKey: SymmetricKey
// Try to load from keychain // Try to load from keychain
if let keyData = keychain.getIdentityKey(forKey: encryptionKeyName) { if let keyData = keychain.getIdentityKey(forKey: encryptionKeyName) {
loadedKey = SymmetricKey(data: keyData) loadedKey = SymmetricKey(data: keyData)
SecureLogger.logKeyOperation("load", keyType: "identity cache encryption key", success: true) SecureLogger.logKeyOperation(.load, keyType: "identity cache encryption key", success: true)
} }
// Generate new key if needed // Generate new key if needed
else { else {
@@ -137,7 +175,7 @@ class SecureIdentityStateManager {
let keyData = loadedKey.withUnsafeBytes { Data($0) } let keyData = loadedKey.withUnsafeBytes { Data($0) }
// Save to keychain // Save to keychain
let saved = keychain.saveIdentityKey(keyData, forKey: encryptionKeyName) let saved = keychain.saveIdentityKey(keyData, forKey: encryptionKeyName)
SecureLogger.logKeyOperation("generate", keyType: "identity cache encryption key", success: saved) SecureLogger.logKeyOperation(.generate, keyType: "identity cache encryption key", success: saved)
} }
self.encryptionKey = loadedKey self.encryptionKey = loadedKey
@@ -146,9 +184,13 @@ class SecureIdentityStateManager {
loadIdentityCache() loadIdentityCache()
} }
deinit {
forceSave()
}
// MARK: - Secure Loading/Saving // MARK: - Secure Loading/Saving
func loadIdentityCache() { private func loadIdentityCache() {
guard let encryptedData = keychain.getIdentityKey(forKey: cacheKey) else { guard let encryptedData = keychain.getIdentityKey(forKey: cacheKey) else {
// No existing cache, start fresh // No existing cache, start fresh
return return
@@ -160,16 +202,11 @@ class SecureIdentityStateManager {
cache = try JSONDecoder().decode(IdentityCache.self, from: decryptedData) cache = try JSONDecoder().decode(IdentityCache.self, from: decryptedData)
} catch { } catch {
// Log error but continue with empty cache // Log error but continue with empty cache
SecureLogger.logError(error, context: "Failed to load identity cache", category: SecureLogger.security) SecureLogger.error(error, context: "Failed to load identity cache", category: .security)
} }
} }
deinit { private func saveIdentityCache() {
// Force save any pending changes
forceSave()
}
func saveIdentityCache() {
// Mark that we need to save // Mark that we need to save
pendingSave = true pendingSave = true
@@ -191,35 +228,17 @@ class SecureIdentityStateManager {
let sealedBox = try AES.GCM.seal(data, using: encryptionKey) let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
let saved = keychain.saveIdentityKey(sealedBox.combined!, forKey: cacheKey) let saved = keychain.saveIdentityKey(sealedBox.combined!, forKey: cacheKey)
if saved { if saved {
SecureLogger.log("Identity cache saved to keychain", category: SecureLogger.security, level: .debug) SecureLogger.debug("Identity cache saved to keychain", category: .security)
} }
} catch { } catch {
SecureLogger.logError(error, context: "Failed to save identity cache", category: SecureLogger.security) SecureLogger.error(error, context: "Failed to save identity cache", category: .security)
} }
} }
// Force immediate save (for app termination) // Force immediate save (for app termination)
func forceSave() { func forceSave() {
saveTimer?.invalidate() saveTimer?.invalidate()
if pendingSave { performSave()
performSave()
}
}
// MARK: - Identity Resolution
func resolveIdentity(peerID: String, claimedNickname: String) -> IdentityHint {
queue.sync {
// Check if we have candidates based on nickname
if let fingerprints = cache.nicknameIndex[claimedNickname] {
if fingerprints.count == 1 {
return .likelyKnown(fingerprint: fingerprints.first!)
} else {
return .ambiguous(candidates: fingerprints)
}
}
return .unknown
}
} }
// MARK: - Social Identity Management // MARK: - Social Identity Management
@@ -229,10 +248,84 @@ class SecureIdentityStateManager {
return cache.socialIdentities[fingerprint] return cache.socialIdentities[fingerprint]
} }
} }
func getAllSocialIdentities() -> [SocialIdentity] { // MARK: - Cryptographic Identities
/// Insert or update a cryptographic identity and optionally persist its signing key and claimed nickname.
/// - Parameters:
/// - fingerprint: SHA-256 hex of the Noise static public key
/// - noisePublicKey: Noise static public key data
/// - signingPublicKey: Optional Ed25519 signing public key for authenticating public messages
/// - claimedNickname: Optional latest claimed nickname to persist into social identity
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String? = nil) {
queue.async(flags: .barrier) {
let now = Date()
if var existing = self.cryptographicIdentities[fingerprint] {
// Update keys if changed
if existing.publicKey != noisePublicKey {
existing = CryptographicIdentity(
fingerprint: fingerprint,
publicKey: noisePublicKey,
signingPublicKey: signingPublicKey ?? existing.signingPublicKey,
firstSeen: existing.firstSeen,
lastHandshake: now
)
self.cryptographicIdentities[fingerprint] = existing
} else {
// Update signing key and lastHandshake
existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey
let updated = CryptographicIdentity(
fingerprint: existing.fingerprint,
publicKey: existing.publicKey,
signingPublicKey: existing.signingPublicKey,
firstSeen: existing.firstSeen,
lastHandshake: now
)
self.cryptographicIdentities[fingerprint] = updated
}
// Persist updated state (already assigned in branches above)
} else {
// New entry
let entry = CryptographicIdentity(
fingerprint: fingerprint,
publicKey: noisePublicKey,
signingPublicKey: signingPublicKey,
firstSeen: now,
lastHandshake: now
)
self.cryptographicIdentities[fingerprint] = entry
}
// Optionally persist claimed nickname into social identity
if let claimed = claimedNickname {
var identity = self.cache.socialIdentities[fingerprint] ?? SocialIdentity(
fingerprint: fingerprint,
localPetname: nil,
claimedNickname: claimed,
trustLevel: .unknown,
isFavorite: false,
isBlocked: false,
notes: nil
)
// Update claimed nickname if changed
if identity.claimedNickname != claimed {
identity.claimedNickname = claimed
self.cache.socialIdentities[fingerprint] = identity
} else if self.cache.socialIdentities[fingerprint] == nil {
self.cache.socialIdentities[fingerprint] = identity
}
}
self.saveIdentityCache()
}
}
/// Find cryptographic identities whose fingerprint prefix matches a peerID (16-hex) short ID
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: PeerID) -> [CryptographicIdentity] {
queue.sync { queue.sync {
return Array(cache.socialIdentities.values) // Defensive: ensure hex and correct length
guard peerID.isShort else { return [] }
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
} }
} }
@@ -310,7 +403,7 @@ class SecureIdentityStateManager {
} }
func setBlocked(_ fingerprint: String, isBlocked: Bool) { func setBlocked(_ fingerprint: String, isBlocked: Bool) {
SecureLogger.log("User \(isBlocked ? "blocked" : "unblocked"): \(fingerprint)", category: SecureLogger.security, level: .info) SecureLogger.info("User \(isBlocked ? "blocked" : "unblocked"): \(fingerprint)", category: .security)
queue.async(flags: .barrier) { queue.async(flags: .barrier) {
if var identity = self.cache.socialIdentities[fingerprint] { if var identity = self.cache.socialIdentities[fingerprint] {
@@ -362,7 +455,7 @@ class SecureIdentityStateManager {
// MARK: - Ephemeral Session Management // MARK: - Ephemeral Session Management
func registerEphemeralSession(peerID: String, handshakeState: HandshakeState = .none) { func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) {
queue.async(flags: .barrier) { queue.async(flags: .barrier) {
self.ephemeralSessions[peerID] = EphemeralIdentity( self.ephemeralSessions[peerID] = EphemeralIdentity(
peerID: peerID, peerID: peerID,
@@ -372,7 +465,7 @@ class SecureIdentityStateManager {
} }
} }
func updateHandshakeState(peerID: String, state: HandshakeState) { func updateHandshakeState(peerID: PeerID, state: HandshakeState) {
queue.async(flags: .barrier) { queue.async(flags: .barrier) {
self.ephemeralSessions[peerID]?.handshakeState = state self.ephemeralSessions[peerID]?.handshakeState = state
@@ -384,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 // MARK: - Cleanup
func clearAllIdentityData() { func clearAllIdentityData() {
SecureLogger.log("Clearing all identity data", category: SecureLogger.security, level: .warning) SecureLogger.warning("Clearing all identity data", category: .security)
queue.async(flags: .barrier) { queue.async(flags: .barrier) {
self.cache = IdentityCache() self.cache = IdentityCache()
self.ephemeralSessions.removeAll() self.ephemeralSessions.removeAll()
self.cryptographicIdentities.removeAll() self.cryptographicIdentities.removeAll()
self.pendingActions.removeAll()
// Delete from keychain // Delete from keychain
let deleted = self.keychain.deleteIdentityKey(forKey: self.cacheKey) let deleted = self.keychain.deleteIdentityKey(forKey: self.cacheKey)
SecureLogger.logKeyOperation("delete", keyType: "identity cache", success: deleted) SecureLogger.logKeyOperation(.delete, keyType: "identity cache", success: deleted)
} }
} }
func removeEphemeralSession(peerID: String) { func removeEphemeralSession(peerID: PeerID) {
queue.async(flags: .barrier) { queue.async(flags: .barrier) {
self.ephemeralSessions.removeValue(forKey: peerID) self.ephemeralSessions.removeValue(forKey: peerID)
self.pendingActions.removeValue(forKey: peerID)
} }
} }
// MARK: - Verification // MARK: - Verification
func setVerified(fingerprint: String, verified: Bool) { func setVerified(fingerprint: String, verified: Bool) {
SecureLogger.log("Fingerprint \(verified ? "verified" : "unverified"): \(fingerprint)", category: SecureLogger.security, level: .info) SecureLogger.info("Fingerprint \(verified ? "verified" : "unverified"): \(fingerprint)", category: .security)
queue.async(flags: .barrier) { queue.async(flags: .barrier) {
if verified { if verified {
+2
View File
@@ -35,6 +35,8 @@
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string> <string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
<key>NSBluetoothPeripheralUsageDescription</key> <key>NSBluetoothPeripheralUsageDescription</key>
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string> <string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
<key>NSCameraUsageDescription</key>
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
<key>NSLocationWhenInUseUsageDescription</key> <key>NSLocationWhenInUseUsageDescription</key>
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string> <string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
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
}
}
+91
View File
@@ -0,0 +1,91 @@
//
// 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
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: 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
}
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)
}
}
+15 -224
View File
@@ -2,12 +2,13 @@ import Foundation
import CoreBluetooth import CoreBluetooth
/// Represents a peer in the BitChat network with all associated metadata /// Represents a peer in the BitChat network with all associated metadata
struct BitchatPeer: Identifiable, Equatable { struct BitchatPeer: Equatable {
let id: String // Hex-encoded peer ID let peerID: PeerID // Hex-encoded peer ID
let noisePublicKey: Data let noisePublicKey: Data
let nickname: String let nickname: String
let lastSeen: Date let lastSeen: Date
let isConnected: Bool let isConnected: Bool
let isReachable: Bool
// Favorite-related properties // Favorite-related properties
var favoriteStatus: FavoritesPersistenceService.FavoriteRelationship? var favoriteStatus: FavoritesPersistenceService.FavoriteRelationship?
@@ -18,6 +19,7 @@ struct BitchatPeer: Identifiable, Equatable {
// Connection state // Connection state
enum ConnectionState { enum ConnectionState {
case bluetoothConnected case bluetoothConnected
case meshReachable // Seen via mesh recently, not directly connected
case nostrAvailable // Mutual favorite, reachable via Nostr case nostrAvailable // Mutual favorite, reachable via Nostr
case offline // Not connected via any transport case offline // Not connected via any transport
} }
@@ -25,6 +27,8 @@ struct BitchatPeer: Identifiable, Equatable {
var connectionState: ConnectionState { var connectionState: ConnectionState {
if isConnected { if isConnected {
return .bluetoothConnected return .bluetoothConnected
} else if isReachable {
return .meshReachable
} else if favoriteStatus?.isMutual == true { } else if favoriteStatus?.isMutual == true {
// Mutual favorites can communicate via Nostr when offline // Mutual favorites can communicate via Nostr when offline
return .nostrAvailable return .nostrAvailable
@@ -47,13 +51,15 @@ struct BitchatPeer: Identifiable, Equatable {
// Display helpers // Display helpers
var displayName: String { var displayName: String {
nickname.isEmpty ? String(id.prefix(8)) : nickname nickname.isEmpty ? String(peerID.id.prefix(8)) : nickname
} }
var statusIcon: String { var statusIcon: String {
switch connectionState { switch connectionState {
case .bluetoothConnected: case .bluetoothConnected:
return "📻" // Radio icon for mesh connection return "📻" // Radio icon for mesh connection
case .meshReachable:
return "📡" // Antenna for mesh reachable
case .nostrAvailable: case .nostrAvailable:
return "🌐" // Purple globe for Nostr return "🌐" // Purple globe for Nostr
case .offline: case .offline:
@@ -67,17 +73,19 @@ struct BitchatPeer: Identifiable, Equatable {
// Initialize from mesh service data // Initialize from mesh service data
init( init(
id: String, peerID: PeerID,
noisePublicKey: Data, noisePublicKey: Data,
nickname: String, nickname: String,
lastSeen: Date = Date(), lastSeen: Date = Date(),
isConnected: Bool = false isConnected: Bool = false,
isReachable: Bool = false
) { ) {
self.id = id self.peerID = peerID
self.noisePublicKey = noisePublicKey self.noisePublicKey = noisePublicKey
self.nickname = nickname self.nickname = nickname
self.lastSeen = lastSeen self.lastSeen = lastSeen
self.isConnected = isConnected self.isConnected = isConnected
self.isReachable = isReachable
// Load favorite status - will be set later by the manager // Load favorite status - will be set later by the manager
self.favoriteStatus = nil self.favoriteStatus = nil
@@ -85,223 +93,6 @@ struct BitchatPeer: Identifiable, Equatable {
} }
static func == (lhs: BitchatPeer, rhs: BitchatPeer) -> Bool { static func == (lhs: BitchatPeer, rhs: BitchatPeer) -> Bool {
lhs.id == rhs.id lhs.peerID == rhs.peerID
}
}
// MARK: - Peer Manager
/// Manages the collection of peers and their states
@MainActor
class PeerManager: ObservableObject {
@Published var peers: [BitchatPeer] = []
@Published var favorites: [BitchatPeer] = []
@Published var mutualFavorites: [BitchatPeer] = []
private let meshService: Transport
private let favoritesService = FavoritesPersistenceService.shared
init(meshService: Transport) {
self.meshService = meshService
updatePeers()
// Listen for updates
NotificationCenter.default.addObserver(
self,
selector: #selector(handleFavoriteChanged),
name: .favoriteStatusChanged,
object: nil
)
}
@objc private func handleFavoriteChanged() {
SecureLogger.log("⭐ Favorite status changed notification received, updating peers",
category: SecureLogger.session, level: .debug)
updatePeers()
}
deinit {
NotificationCenter.default.removeObserver(self)
}
func updatePeers() {
// Reduce log verbosity - only log when count changes
let previousCount = peers.count
// Get current mesh peers
let meshPeers = meshService.getPeerNicknames()
// Build peer list
var allPeers: [BitchatPeer] = []
var connectedNicknames: Set<String> = []
var addedPeerIDs: Set<String> = []
// Add connected mesh peers (only if actually connected or relay connected)
for (peerID, nickname) in meshPeers {
guard let noiseKey = Data(hexString: peerID) else { continue }
// Safety check: Never add our own peer ID
if peerID == meshService.myPeerID {
continue
}
// Check if this peer is actually connected
let isConnected = meshService.isPeerConnected(peerID)
// Skip disconnected peers unless they're favorites (handled later)
if !isConnected {
continue
}
if isConnected {
connectedNicknames.insert(nickname)
}
// Track that we've added this peer ID
addedPeerIDs.insert(peerID)
var peer = BitchatPeer(
id: peerID,
noisePublicKey: noiseKey,
nickname: nickname,
isConnected: isConnected
)
// Set favorite status - check both by current noise key and by nickname
if let favoriteStatus = favoritesService.getFavoriteStatus(for: noiseKey) {
peer.favoriteStatus = favoriteStatus
peer.nostrPublicKey = favoriteStatus.peerNostrPublicKey
} else {
// Check if we have a favorite for this nickname (peer may have reconnected with new ID)
let favoriteByNickname = favoritesService.favorites.values.first { $0.peerNickname == nickname }
if let favorite = favoriteByNickname {
SecureLogger.log("🔄 Found favorite for '\(nickname)' by nickname, updating noise key",
category: SecureLogger.session, level: .info)
// Update the favorite's noise key to match the current connection
favoritesService.updateNoisePublicKey(from: favorite.peerNoisePublicKey, to: noiseKey, peerNickname: nickname)
// Get the updated favorite with the new key
peer.favoriteStatus = favoritesService.getFavoriteStatus(for: noiseKey)
peer.nostrPublicKey = peer.favoriteStatus?.peerNostrPublicKey ?? favorite.peerNostrPublicKey
}
}
allPeers.append(peer)
}
// Add offline favorites (only those not currently connected AND that we actively favorite)
for (favoriteKey, favorite) in favoritesService.favorites {
let favoriteID = favorite.peerNoisePublicKey.hexEncodedString()
// Skip if this peer is already connected (by nickname)
if connectedNicknames.contains(favorite.peerNickname) {
// Skipping favorite - already connected
continue
}
// Skip if we already added a peer with this ID (prevents duplicates)
if addedPeerIDs.contains(favoriteID) {
// Skipping favorite - peer ID already added
continue
}
// Only add peers that WE favorite (not just ones who favorite us)
if !favorite.isFavorite {
// Skipping - we don't favorite them
continue
}
// Add this favorite as an offline peer
SecureLogger.log(" - Adding offline favorite '\(favorite.peerNickname)' (key: \(favoriteKey.hexEncodedString()), ID: \(favoriteID), mutual: \(favorite.isMutual))",
category: SecureLogger.session, level: .info)
var peer = BitchatPeer(
id: favoriteID,
noisePublicKey: favorite.peerNoisePublicKey,
nickname: favorite.peerNickname,
isConnected: false
)
// Set favorite status
peer.favoriteStatus = favorite
peer.nostrPublicKey = favorite.peerNostrPublicKey
addedPeerIDs.insert(favoriteID) // Track that we've added this ID
allPeers.append(peer)
}
// Filter out "Unknown" peers unless they are favorites or have a favorite relationship
allPeers = allPeers.filter { peer in
!(peer.displayName == "Unknown" && peer.favoriteStatus == nil)
}
// Sort: Connected first, then favorites, then alphabetical
allPeers.sort { lhs, rhs in
// Direct connections first
if lhs.isConnected != rhs.isConnected {
return lhs.isConnected
}
// Then favorites
if lhs.isFavorite != rhs.isFavorite {
return lhs.isFavorite
}
// Finally alphabetical
return lhs.displayName < rhs.displayName
}
// Single pass to compute all subsets and counts
var favorites: [BitchatPeer] = []
var mutualFavorites: [BitchatPeer] = []
var connectedCount = 0
var offlineCount = 0
for peer in allPeers {
if peer.isFavorite {
favorites.append(peer)
}
if peer.isMutualFavorite {
mutualFavorites.append(peer)
}
if peer.isConnected {
connectedCount += 1
} else {
offlineCount += 1
}
}
// Final safety check: ensure no duplicate IDs
var finalPeers: [BitchatPeer] = []
var seenIDs: Set<String> = []
for peer in allPeers {
if !seenIDs.contains(peer.id) {
seenIDs.insert(peer.id)
finalPeers.append(peer)
} else {
SecureLogger.log("⚠️ Removing duplicate peer ID in final check: \(peer.id) (\(peer.displayName))",
category: SecureLogger.session, level: .warning)
}
}
self.peers = finalPeers
self.favorites = favorites
self.mutualFavorites = mutualFavorites
// Log peer list summary sparingly at debug level
if favoritesService.favorites.count > 0 {
SecureLogger.log("📊 Peer list update: \(allPeers.count) total (\(connectedCount) connected, \(offlineCount) offline), \(favorites.count) favorites, \(mutualFavorites.count) mutual",
category: SecureLogger.session, level: .debug)
} else if previousCount != allPeers.count {
SecureLogger.log("✅ Updated peer list: \(allPeers.count) total peers",
category: SecureLogger.session, level: .debug)
}
}
func toggleFavorite(_ peer: BitchatPeer) {
if peer.isFavorite {
favoritesService.removeFavorite(peerNoisePublicKey: peer.noisePublicKey)
} else {
favoritesService.addFavorite(
peerNoisePublicKey: peer.noisePublicKey,
peerNostrPublicKey: peer.nostrPublicKey,
peerNickname: peer.nickname
)
}
updatePeers()
} }
} }
+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)
}
}
+217
View File
@@ -0,0 +1,217 @@
//
// 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)
}
}
// 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())
}
}
// 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
}
}
// 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: - String Interop Helpers
// MARK: CustomStringConvertible
extension PeerID: CustomStringConvertible {
/// So it returns the actual `id` like before even inside another String
var description: String {
id
}
}
// MARK: Custom Equatable w/ String & Optionality
// PeerID <> String
extension Optional where Wrapped == PeerID {
static func ==(lhs: Optional<Wrapped>, rhs: Optional<String>) -> Bool { lhs?.id == rhs }
static func !=(lhs: Optional<Wrapped>, rhs: Optional<String>) -> Bool { lhs?.id != rhs }
}
// String <> PeerID
extension Optional where Wrapped == String {
static func ==(lhs: Optional<Wrapped>, rhs: Optional<PeerID>) -> Bool { lhs == rhs?.id }
static func !=(lhs: Optional<Wrapped>, rhs: Optional<PeerID>) -> Bool { lhs != rhs?.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: 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 PeerID(str: 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)
}
}
+63
View File
@@ -0,0 +1,63 @@
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
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)
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
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
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)
}
}
@@ -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()
}
}
}
+31 -23
View File
@@ -77,9 +77,9 @@
/// - Noise Specification: http://www.noiseprotocol.org/noise.html /// - Noise Specification: http://www.noiseprotocol.org/noise.html
/// ///
import BitLogger
import Foundation import Foundation
import CryptoKit import CryptoKit
import os.log
// Core Noise Protocol implementation // Core Noise Protocol implementation
// Based on the Noise Protocol Framework specification // Based on the Noise Protocol Framework specification
@@ -127,7 +127,7 @@ struct NoiseProtocolName {
/// Handles ChaCha20-Poly1305 AEAD encryption with automatic nonce management /// Handles ChaCha20-Poly1305 AEAD encryption with automatic nonce management
/// and replay protection using a sliding window algorithm. /// and replay protection using a sliding window algorithm.
/// - Warning: Nonce reuse would be catastrophic for security /// - Warning: Nonce reuse would be catastrophic for security
class NoiseCipherState { final class NoiseCipherState {
// Constants for replay protection // Constants for replay protection
private static let NONCE_SIZE_BYTES = 4 private static let NONCE_SIZE_BYTES = 4
private static let REPLAY_WINDOW_SIZE = 1024 private static let REPLAY_WINDOW_SIZE = 1024
@@ -285,7 +285,7 @@ class NoiseCipherState {
// Log high nonce values that might indicate issues // Log high nonce values that might indicate issues
if currentNonce > Self.HIGH_NONCE_WARNING_THRESHOLD { if currentNonce > Self.HIGH_NONCE_WARNING_THRESHOLD {
SecureLogger.log("High nonce value detected: \(currentNonce) - consider rekeying", category: SecureLogger.encryption, level: .warning) SecureLogger.warning("High nonce value detected: \(currentNonce) - consider rekeying", category: .encryption)
} }
return combinedPayload return combinedPayload
@@ -307,13 +307,13 @@ class NoiseCipherState {
if useExtractedNonce { if useExtractedNonce {
// Extract nonce and ciphertext from combined payload // Extract nonce and ciphertext from combined payload
guard let (extractedNonce, actualCiphertext) = try extractNonceFromCiphertextPayload(ciphertext) else { guard let (extractedNonce, actualCiphertext) = try extractNonceFromCiphertextPayload(ciphertext) else {
SecureLogger.log("Decrypt failed: Could not extract nonce from payload") SecureLogger.debug("Decrypt failed: Could not extract nonce from payload")
throw NoiseError.invalidCiphertext throw NoiseError.invalidCiphertext
} }
// Validate nonce with sliding window replay protection // Validate nonce with sliding window replay protection
guard isValidNonce(extractedNonce) else { guard isValidNonce(extractedNonce) else {
SecureLogger.log("Replay attack detected: nonce \(extractedNonce) rejected") SecureLogger.debug("Replay attack detected: nonce \(extractedNonce) rejected")
throw NoiseError.replayDetected throw NoiseError.replayDetected
} }
@@ -342,7 +342,7 @@ class NoiseCipherState {
// Log high nonce values that might indicate issues // Log high nonce values that might indicate issues
if decryptionNonce > Self.HIGH_NONCE_WARNING_THRESHOLD { if decryptionNonce > Self.HIGH_NONCE_WARNING_THRESHOLD {
SecureLogger.log("High nonce value detected: \(decryptionNonce) - consider rekeying", category: SecureLogger.encryption, level: .warning) SecureLogger.warning("High nonce value detected: \(decryptionNonce) - consider rekeying", category: .encryption)
} }
do { do {
@@ -355,9 +355,9 @@ class NoiseCipherState {
nonce += 1 nonce += 1
return plaintext return plaintext
} catch { } catch {
SecureLogger.log("Decrypt failed: \(error) for nonce \(decryptionNonce)") SecureLogger.debug("Decrypt failed: \(error) for nonce \(decryptionNonce)")
// Log authentication failures with nonce info // Log authentication failures with nonce info
SecureLogger.log("Decryption failed at nonce \(decryptionNonce)", category: SecureLogger.encryption, level: .error) SecureLogger.error("Decryption failed at nonce \(decryptionNonce)", category: .encryption)
throw error throw error
} }
} }
@@ -384,7 +384,7 @@ class NoiseCipherState {
/// Responsible for key derivation, protocol name hashing, and maintaining /// Responsible for key derivation, protocol name hashing, and maintaining
/// the chaining key that provides key separation between handshake messages. /// the chaining key that provides key separation between handshake messages.
/// - Note: This class implements the SymmetricState object from the Noise spec /// - Note: This class implements the SymmetricState object from the Noise spec
class NoiseSymmetricState { final class NoiseSymmetricState {
private var cipherState: NoiseCipherState private var cipherState: NoiseCipherState
private var chainingKey: Data private var chainingKey: Data
private var hash: Data private var hash: Data
@@ -397,7 +397,7 @@ class NoiseSymmetricState {
if nameData.count <= 32 { if nameData.count <= 32 {
self.hash = nameData + Data(repeating: 0, count: 32 - nameData.count) self.hash = nameData + Data(repeating: 0, count: 32 - nameData.count)
} else { } else {
self.hash = Data(SHA256.hash(data: nameData)) self.hash = nameData.sha256Hash()
} }
self.chainingKey = self.hash self.chainingKey = self.hash
} }
@@ -410,7 +410,7 @@ class NoiseSymmetricState {
} }
func mixHash(_ data: Data) { func mixHash(_ data: Data) {
hash = Data(SHA256.hash(data: hash + data)) hash = (hash + data).sha256Hash()
} }
func mixKeyAndHash(_ inputKeyMaterial: Data) { func mixKeyAndHash(_ inputKeyMaterial: Data) {
@@ -488,9 +488,10 @@ class NoiseSymmetricState {
/// This is the main interface for establishing encrypted sessions between peers. /// This is the main interface for establishing encrypted sessions between peers.
/// Manages the handshake state machine, message patterns, and key derivation. /// Manages the handshake state machine, message patterns, and key derivation.
/// - Important: Each handshake instance should only be used once /// - Important: Each handshake instance should only be used once
class NoiseHandshakeState { final class NoiseHandshakeState {
private let role: NoiseRole private let role: NoiseRole
private let pattern: NoisePattern private let pattern: NoisePattern
private let keychain: KeychainManagerProtocol
private var symmetricState: NoiseSymmetricState private var symmetricState: NoiseSymmetricState
// Keys // Keys
@@ -506,9 +507,16 @@ class NoiseHandshakeState {
private var messagePatterns: [[NoiseMessagePattern]] = [] private var messagePatterns: [[NoiseMessagePattern]] = []
private var currentPattern = 0 private var currentPattern = 0
init(role: NoiseRole, pattern: NoisePattern, localStaticKey: Curve25519.KeyAgreement.PrivateKey? = nil, remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil) { init(
role: NoiseRole,
pattern: NoisePattern,
keychain: KeychainManagerProtocol,
localStaticKey: Curve25519.KeyAgreement.PrivateKey? = nil,
remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil
) {
self.role = role self.role = role
self.pattern = pattern self.pattern = pattern
self.keychain = keychain
// Initialize static keys // Initialize static keys
if let localKey = localStaticKey { if let localKey = localStaticKey {
@@ -579,7 +587,7 @@ class NoiseHandshakeState {
var sharedData = shared.withUnsafeBytes { Data($0) } var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData) symmetricState.mixKey(sharedData)
// Clear sensitive shared secret // Clear sensitive shared secret
KeychainManager.secureClear(&sharedData) keychain.secureClear(&sharedData)
case .es: case .es:
// DH(ephemeral, static) - direction depends on role // DH(ephemeral, static) - direction depends on role
@@ -627,7 +635,7 @@ class NoiseHandshakeState {
var sharedData = shared.withUnsafeBytes { Data($0) } var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData) symmetricState.mixKey(sharedData)
// Clear sensitive shared secret // Clear sensitive shared secret
KeychainManager.secureClear(&sharedData) keychain.secureClear(&sharedData)
} }
} }
@@ -661,7 +669,7 @@ class NoiseHandshakeState {
do { do {
remoteEphemeralPublic = try NoiseHandshakeState.validatePublicKey(ephemeralData) remoteEphemeralPublic = try NoiseHandshakeState.validatePublicKey(ephemeralData)
} catch { } catch {
SecureLogger.log("Invalid ephemeral public key received", category: SecureLogger.security, level: .warning) SecureLogger.warning("Invalid ephemeral public key received", category: .security)
throw NoiseError.invalidMessage throw NoiseError.invalidMessage
} }
symmetricState.mixHash(ephemeralData) symmetricState.mixHash(ephemeralData)
@@ -678,7 +686,7 @@ class NoiseHandshakeState {
let decrypted = try symmetricState.decryptAndHash(staticData) let decrypted = try symmetricState.decryptAndHash(staticData)
remoteStaticPublic = try NoiseHandshakeState.validatePublicKey(decrypted) remoteStaticPublic = try NoiseHandshakeState.validatePublicKey(decrypted)
} catch { } catch {
SecureLogger.logSecurityEvent(.authenticationFailed(peerID: "Unknown - handshake"), level: .error) SecureLogger.error(.authenticationFailed(peerID: "Unknown - handshake"))
throw NoiseError.authenticationFailure throw NoiseError.authenticationFailure
} }
@@ -715,7 +723,7 @@ class NoiseHandshakeState {
var sharedData = shared.withUnsafeBytes { Data($0) } var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData) symmetricState.mixKey(sharedData)
// Clear sensitive shared secret // Clear sensitive shared secret
KeychainManager.secureClear(&sharedData) keychain.secureClear(&sharedData)
} else { } else {
guard let localStatic = localStaticPrivate, guard let localStatic = localStaticPrivate,
let remoteEphemeral = remoteEphemeralPublic else { let remoteEphemeral = remoteEphemeralPublic else {
@@ -725,7 +733,7 @@ class NoiseHandshakeState {
var sharedData = shared.withUnsafeBytes { Data($0) } var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData) symmetricState.mixKey(sharedData)
// Clear sensitive shared secret // Clear sensitive shared secret
KeychainManager.secureClear(&sharedData) keychain.secureClear(&sharedData)
} }
case .se: case .se:
@@ -738,7 +746,7 @@ class NoiseHandshakeState {
var sharedData = shared.withUnsafeBytes { Data($0) } var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData) symmetricState.mixKey(sharedData)
// Clear sensitive shared secret // Clear sensitive shared secret
KeychainManager.secureClear(&sharedData) keychain.secureClear(&sharedData)
} else { } else {
guard let localEphemeral = localEphemeralPrivate, guard let localEphemeral = localEphemeralPrivate,
let remoteStatic = remoteStaticPublic else { let remoteStatic = remoteStaticPublic else {
@@ -748,7 +756,7 @@ class NoiseHandshakeState {
var sharedData = shared.withUnsafeBytes { Data($0) } var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData) symmetricState.mixKey(sharedData)
// Clear sensitive shared secret // Clear sensitive shared secret
KeychainManager.secureClear(&sharedData) keychain.secureClear(&sharedData)
} }
case .ss: case .ss:
@@ -877,7 +885,7 @@ extension NoiseHandshakeState {
// Check against known bad points // Check against known bad points
if lowOrderPoints.contains(keyData) { if lowOrderPoints.contains(keyData) {
SecureLogger.log("Low-order point detected", category: SecureLogger.security, level: .warning) SecureLogger.warning("Low-order point detected", category: .security)
throw NoiseError.invalidPublicKey throw NoiseError.invalidPublicKey
} }
@@ -887,7 +895,7 @@ extension NoiseHandshakeState {
return publicKey return publicKey
} catch { } catch {
// If CryptoKit rejects it, it's invalid // If CryptoKit rejects it, it's invalid
SecureLogger.log("CryptoKit validation failed", category: SecureLogger.security, level: .warning) SecureLogger.warning("CryptoKit validation failed", category: .security)
throw NoiseError.invalidPublicKey throw NoiseError.invalidPublicKey
} }
} }
+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
}
}
+24 -272
View File
@@ -6,37 +6,14 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import BitLogger
import Foundation import Foundation
import CryptoKit 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 { class NoiseSession {
let peerID: String let peerID: PeerID
let role: NoiseRole let role: NoiseRole
private let keychain: KeychainManagerProtocol
private var state: NoiseSessionState = .uninitialized private var state: NoiseSessionState = .uninitialized
private var handshakeState: NoiseHandshakeState? private var handshakeState: NoiseHandshakeState?
private var sendCipher: NoiseCipherState? private var sendCipher: NoiseCipherState?
@@ -53,9 +30,16 @@ class NoiseSession {
// Thread safety // Thread safety
private let sessionQueue = DispatchQueue(label: "chat.bitchat.noise.session", attributes: .concurrent) private let sessionQueue = DispatchQueue(label: "chat.bitchat.noise.session", attributes: .concurrent)
init(peerID: String, role: NoiseRole, localStaticKey: Curve25519.KeyAgreement.PrivateKey, remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil) { init(
peerID: PeerID,
role: NoiseRole,
keychain: KeychainManagerProtocol,
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil
) {
self.peerID = peerID self.peerID = peerID
self.role = role self.role = role
self.keychain = keychain
self.localStaticKey = localStaticKey self.localStaticKey = localStaticKey
self.remoteStaticPublicKey = remoteStaticKey self.remoteStaticPublicKey = remoteStaticKey
} }
@@ -72,6 +56,7 @@ class NoiseSession {
handshakeState = NoiseHandshakeState( handshakeState = NoiseHandshakeState(
role: role, role: role,
pattern: .XX, pattern: .XX,
keychain: keychain,
localStaticKey: localStaticKey, localStaticKey: localStaticKey,
remoteStaticKey: nil remoteStaticKey: nil
) )
@@ -92,18 +77,19 @@ class NoiseSession {
func processHandshakeMessage(_ message: Data) throws -> Data? { func processHandshakeMessage(_ message: Data) throws -> Data? {
return try sessionQueue.sync(flags: .barrier) { return try sessionQueue.sync(flags: .barrier) {
SecureLogger.log("NoiseSession[\(peerID)]: Processing handshake message, current state: \(state), role: \(role)", category: SecureLogger.noise, level: .debug) SecureLogger.debug("NoiseSession[\(peerID)]: Processing handshake message, current state: \(state), role: \(role)")
// Initialize handshake state if needed (for responders) // Initialize handshake state if needed (for responders)
if state == .uninitialized && role == .responder { if state == .uninitialized && role == .responder {
handshakeState = NoiseHandshakeState( handshakeState = NoiseHandshakeState(
role: role, role: role,
pattern: .XX, pattern: .XX,
keychain: keychain,
localStaticKey: localStaticKey, localStaticKey: localStaticKey,
remoteStaticKey: nil remoteStaticKey: nil
) )
state = .handshaking state = .handshaking
SecureLogger.log("NoiseSession[\(peerID)]: Initialized handshake state for responder", category: SecureLogger.noise, level: .debug) SecureLogger.debug("NoiseSession[\(peerID)]: Initialized handshake state for responder")
} }
guard case .handshaking = state, let handshake = handshakeState else { guard case .handshaking = state, let handshake = handshakeState else {
@@ -112,7 +98,7 @@ class NoiseSession {
// Process incoming message // Process incoming message
_ = try handshake.readMessage(message) _ = try handshake.readMessage(message)
SecureLogger.log("NoiseSession[\(peerID)]: Read handshake message, checking if complete", category: SecureLogger.noise, level: .debug) SecureLogger.debug("NoiseSession[\(peerID)]: Read handshake message, checking if complete")
// Check if handshake is complete // Check if handshake is complete
if handshake.isHandshakeComplete() { if handshake.isHandshakeComplete() {
@@ -130,15 +116,15 @@ class NoiseSession {
state = .established state = .established
handshakeState = nil // Clear handshake state handshakeState = nil // Clear handshake state
SecureLogger.log("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established", category: SecureLogger.noise, level: .debug) SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established")
SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID)) SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
return nil return nil
} else { } else {
// Generate response // Generate response
let response = try handshake.writeMessage() let response = try handshake.writeMessage()
sentHandshakeMessages.append(response) sentHandshakeMessages.append(response)
SecureLogger.log("NoiseSession[\(peerID)]: Generated handshake response of size \(response.count)", category: SecureLogger.noise, level: .debug) SecureLogger.debug("NoiseSession[\(peerID)]: Generated handshake response of size \(response.count)")
// Check if handshake is complete after writing // Check if handshake is complete after writing
if handshake.isHandshakeComplete() { if handshake.isHandshakeComplete() {
@@ -156,8 +142,8 @@ class NoiseSession {
state = .established state = .established
handshakeState = nil // Clear handshake state handshakeState = nil // Clear handshake state
SecureLogger.log("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established", category: SecureLogger.noise, level: .debug) SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established")
SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID)) SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
} }
return response return response
@@ -210,12 +196,6 @@ class NoiseSession {
} }
} }
func getHandshakeHash() -> Data? {
return sessionQueue.sync {
return handshakeHash
}
}
func reset() { func reset() {
sessionQueue.sync(flags: .barrier) { sessionQueue.sync(flags: .barrier) {
let wasEstablished = state == .established let wasEstablished = state == .established
@@ -231,247 +211,19 @@ class NoiseSession {
// Clear sent handshake messages // Clear sent handshake messages
for i in 0..<sentHandshakeMessages.count { for i in 0..<sentHandshakeMessages.count {
var message = sentHandshakeMessages[i] var message = sentHandshakeMessages[i]
KeychainManager.secureClear(&message) keychain.secureClear(&message)
} }
sentHandshakeMessages.removeAll() sentHandshakeMessages.removeAll()
// Clear handshake hash // Clear handshake hash
if var hash = handshakeHash { if var hash = handshakeHash {
KeychainManager.secureClear(&hash) keychain.secureClear(&hash)
} }
handshakeHash = nil handshakeHash = nil
if wasEstablished { if wasEstablished {
SecureLogger.logSecurityEvent(.sessionExpired(peerID: peerID)) SecureLogger.info(.sessionExpired(peerID: peerID.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
}
}
+53 -24
View File
@@ -1,4 +1,6 @@
import BitLogger
import Foundation import Foundation
import Tor
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing. /// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
@MainActor @MainActor
@@ -14,7 +16,7 @@ final class GeoRelayDirectory {
private let cacheFileName = "georelays_cache.csv" private let cacheFileName = "georelays_cache.csv"
private let lastFetchKey = "georelay.lastFetchAt" private let lastFetchKey = "georelay.lastFetchAt"
private let remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")! private let remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!
private let fetchInterval: TimeInterval = 60 * 60 * 24 // 24h private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds // 24h
private init() { private init() {
// Load cached or bundled data synchronously // Load cached or bundled data synchronously
@@ -31,13 +33,32 @@ final class GeoRelayDirectory {
/// Returns up to `count` relay URLs (wss://) closest to the given coordinate. /// Returns up to `count` relay URLs (wss://) closest to the given coordinate.
func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] { func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {
guard !entries.isEmpty else { return [] } guard !entries.isEmpty, count > 0 else { return [] }
let sorted = entries
.sorted { a, b in if entries.count <= count {
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon) 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 // MARK: - Remote Fetch
@@ -50,23 +71,31 @@ final class GeoRelayDirectory {
private func fetchRemote() { private func fetchRemote() {
let req = URLRequest(url: remoteURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15) let req = URLRequest(url: remoteURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
let task = URLSession.shared.dataTask(with: req) { [weak self] data, _, error in // Ensure Tor readiness before fetching (fail-closed by default)
guard let self = self else { return } Task.detached {
if let data = data, error == nil, let text = String(data: data, encoding: .utf8) { let ready = await TorManager.shared.awaitReady()
let parsed = GeoRelayDirectory.parseCSV(text) if !ready {
if !parsed.isEmpty { SecureLogger.warning("GeoRelayDirectory: Tor not ready; skipping remote fetch (fail-closed)", category: .session)
Task { @MainActor in return
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)
}
return
}
} }
SecureLogger.log("GeoRelayDirectory: remote fetch failed; keeping local entries", category: SecureLogger.session, level: .warning) let task = TorURLSession.shared.session.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.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
}
return
}
}
SecureLogger.warning("GeoRelayDirectory: remote fetch failed; keeping local entries", category: .session)
}
task.resume()
} }
task.resume()
} }
private func persistCache(_ text: String) { private func persistCache(_ text: String) {
@@ -74,7 +103,7 @@ final class GeoRelayDirectory {
do { do {
try text.data(using: .utf8)?.write(to: url, options: .atomic) try text.data(using: .utf8)?.write(to: url, options: .atomic)
} catch { } catch {
SecureLogger.log("GeoRelayDirectory: failed to write cache: \(error)", category: SecureLogger.session, level: .warning) SecureLogger.warning("GeoRelayDirectory: failed to write cache: \(error)", category: .session)
} }
} }
@@ -105,7 +134,7 @@ final class GeoRelayDirectory {
let text = String(data: data, encoding: .utf8) { let text = String(data: data, encoding: .utf8) {
return Self.parseCSV(text) return Self.parseCSV(text)
} }
SecureLogger.log("GeoRelayDirectory: no local CSV found; entries empty", category: SecureLogger.session, level: .warning) SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session)
return [] return []
} }
+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)
}
}
+1 -1
View File
@@ -100,7 +100,7 @@ struct NostrEmbeddedBitChat {
if let maybeData = Data(hexString: recipientPeerID) { if let maybeData = Data(hexString: recipientPeerID) {
if maybeData.count == 32 { if maybeData.count == 32 {
// Treat as Noise static public key; derive peerID from fingerprint // Treat as Noise static public key; derive peerID from fingerprint
return PeerIDUtils.derivePeerID(fromPublicKey: maybeData) return PeerID(publicKey: maybeData).id
} else if maybeData.count == 8 { } else if maybeData.count == 8 {
// Already an 8-byte peer ID // Already an 8-byte peer ID
return recipientPeerID return recipientPeerID
-293
View File
@@ -1,50 +1,5 @@
import Foundation import Foundation
import CryptoKit
import P256K 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 /// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging
struct NostrIdentity: Codable { struct NostrIdentity: Codable {
@@ -103,251 +58,3 @@ struct NostrIdentity: Codable {
return publicKey.hexEncodedString() 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
+135
View File
@@ -0,0 +1,135 @@
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?
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 {
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) {
return identity
}
}
// As a final fallback, hash the seed+msg and try again
let fallback = (seed + msg).sha256Hash()
return try NostrIdentity(privateKeyData: fallback)
}
}
+37 -24
View File
@@ -1,3 +1,4 @@
import BitLogger
import Foundation import Foundation
import CryptoKit import CryptoKit
import P256K import P256K
@@ -78,8 +79,7 @@ struct NostrProtocol {
) )
// Successfully unwrapped gift wrap // Successfully unwrapped gift wrap
} catch { } catch {
SecureLogger.log("❌ Failed to unwrap gift wrap: \(error)", SecureLogger.error("❌ Failed to unwrap gift wrap: \(error)", category: .session)
category: SecureLogger.session, level: .error)
throw error throw error
} }
@@ -92,8 +92,7 @@ struct NostrProtocol {
) )
// Successfully opened seal // Successfully opened seal
} catch { } catch {
SecureLogger.log("❌ Failed to open seal: \(error)", SecureLogger.error("❌ Failed to open seal: \(error)", category: .session)
category: SecureLogger.session, level: .error)
throw error throw error
} }
@@ -109,7 +108,7 @@ struct NostrProtocol {
teleported: Bool = false teleported: Bool = false
) throws -> NostrEvent { ) throws -> NostrEvent {
var tags = [["g", geohash]] var tags = [["g", geohash]]
if let nickname = nickname, !nickname.isEmpty { if let nickname = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty {
tags.append(["n", nickname]) tags.append(["n", nickname])
} }
if teleported { if teleported {
@@ -122,8 +121,30 @@ struct NostrProtocol {
tags: tags, tags: tags,
content: content content: content
) )
let signingKey = try senderIdentity.signingKey() let schnorrKey = try senderIdentity.schnorrSigningKey()
return try event.sign(with: signingKey) 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 // MARK: - Private Methods
@@ -149,9 +170,8 @@ struct NostrProtocol {
content: encrypted content: encrypted
) )
// Convert to P256K.Signing.PrivateKey for signing (temporary until we update sign method) // Sign the seal with the sender's Schnorr private key
let signingKey = try P256K.Signing.PrivateKey(dataRepresentation: senderKey.dataRepresentation) return try seal.sign(with: senderKey)
return try seal.sign(with: signingKey)
} }
private static func createGiftWrap( private static func createGiftWrap(
@@ -181,9 +201,8 @@ struct NostrProtocol {
content: encrypted content: encrypted
) )
// Convert to P256K.Signing.PrivateKey for signing (temporary until we update sign method) // Sign the gift wrap with the wrap Schnorr private key
let signingKey = try P256K.Signing.PrivateKey(dataRepresentation: wrapKey.dataRepresentation) return try giftWrap.sign(with: wrapKey)
return try giftWrap.sign(with: signingKey)
} }
private static func unwrapGiftWrap( private static func unwrapGiftWrap(
@@ -416,7 +435,7 @@ struct NostrProtocol {
// Log with explicit UTC and local time for debugging // Log with explicit UTC and local time for debugging
let formatter = DateFormatter() let formatter = DateFormatter()
// Removed unnecessary date formatting operations //
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
formatter.timeZone = TimeZone(abbreviation: "UTC") formatter.timeZone = TimeZone(abbreviation: "UTC")
@@ -472,19 +491,16 @@ struct NostrEvent: Codable {
self.sig = dict["sig"] as? String self.sig = dict["sig"] as? String
} }
func sign(with key: P256K.Signing.PrivateKey) throws -> NostrEvent { func sign(with key: P256K.Schnorr.PrivateKey) throws -> NostrEvent {
let (eventId, eventIdHash) = try calculateEventId() let (eventId, eventIdHash) = try calculateEventId()
// Convert to Schnorr key for Nostr signing // Sign with Schnorr (BIP-340)
let schnorrKey = try P256K.Schnorr.PrivateKey(dataRepresentation: key.dataRepresentation)
// Sign with Schnorr
var messageBytes = [UInt8](eventIdHash) var messageBytes = [UInt8](eventIdHash)
var auxRand = [UInt8](repeating: 0, count: 32) var auxRand = [UInt8](repeating: 0, count: 32)
_ = auxRand.withUnsafeMutableBytes { ptr in _ = auxRand.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!) SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
} }
let schnorrSignature = try schnorrKey.signature(message: &messageBytes, auxiliaryRand: &auxRand) let schnorrSignature = try key.signature(message: &messageBytes, auxiliaryRand: &auxRand)
let signatureHex = schnorrSignature.dataRepresentation.hexEncodedString() let signatureHex = schnorrSignature.dataRepresentation.hexEncodedString()
@@ -505,10 +521,7 @@ struct NostrEvent: Codable {
] as [Any] ] as [Any]
let data = try JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes]) let data = try JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
let hash = CryptoKit.SHA256.hash(data: data) return (data.sha256Fingerprint(), data.sha256Hash())
let hashData = Data(hash)
let hashHex = hash.compactMap { String(format: "%02x", $0) }.joined()
return (hashHex, hashData)
} }
func jsonString() throws -> String { func jsonString() throws -> String {
+504 -169
View File
@@ -1,10 +1,12 @@
import BitLogger
import Foundation import Foundation
import Network import Network
import Combine import Combine
import Tor
/// Manages WebSocket connections to Nostr relays /// Manages WebSocket connections to Nostr relays
@MainActor @MainActor
class NostrRelayManager: ObservableObject { final class NostrRelayManager: ObservableObject {
static let shared = NostrRelayManager() static let shared = NostrRelayManager()
// Track gift-wraps (kind 1059) we initiated so we can log OK acks at info // Track gift-wraps (kind 1059) we initiated so we can log OK acks at info
private(set) static var pendingGiftWrapIDs = Set<String>() private(set) static var pendingGiftWrapIDs = Set<String>()
@@ -34,77 +36,213 @@ class NostrRelayManager: ObservableObject {
"wss://nostr21.com" "wss://nostr21.com"
// For local testing, you can add: "ws://localhost:8080" // 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 relays: [Relay] = []
@Published private(set) var isConnected = false @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 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] = [:] 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>() 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 // 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 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 // Exponential backoff configuration
private let initialBackoffInterval: TimeInterval = 1.0 // Start with 1 second private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
private let maxBackoffInterval: TimeInterval = 300.0 // Max 5 minutes private let maxBackoffInterval: TimeInterval = TransportConfig.nostrRelayMaxBackoffSeconds
private let backoffMultiplier: Double = 2.0 // Double each time private let backoffMultiplier: Double = TransportConfig.nostrRelayBackoffMultiplier
private let maxReconnectAttempts = 10 // Stop after 10 attempts private let maxReconnectAttempts = TransportConfig.nostrRelayMaxReconnectAttempts
// Reconnection timer // Reconnection timer
private var reconnectionTimer: Timer? private var reconnectionTimer: Timer?
// Bump generation to invalidate scheduled reconnects when we reset/disconnect
private var connectionGeneration: Int = 0
init() { init() {
// Initialize with default relays hasMutualFavorites = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
self.relays = Self.defaultRelays.map { Relay(url: $0) } 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 /// Connect to all configured relays
func connect() { func connect() {
SecureLogger.log("🌐 Connecting to \(relays.count) Nostr relays", category: SecureLogger.session, level: .debug) // Global network policy gate
for relay in relays { guard networkService.activationAllowed else { return }
connectToRelay(relay.url) 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 /// Disconnect from all relays
func disconnect() { func disconnect() {
connectionGeneration &+= 1
for (_, task) in connections { for (_, task) in connections {
task.cancel(with: .goingAway, reason: nil) task.cancel(with: .goingAway, reason: nil)
} }
connections.removeAll() connections.removeAll()
// Clear known subscriptions and any queued subs since connections are gone
subscriptions.removeAll()
pendingSubscriptions.removeAll()
updateConnectionStatus() updateConnectionStatus()
} }
/// Ensure connections exist to the given relay URLs (idempotent). /// Ensure connections exist to the given relay URLs (idempotent).
func ensureConnections(to relayUrls: [String]) { func ensureConnections(to relayUrls: [String]) {
let existing = Set(relays.map { $0.url }) // Global network policy gate
for url in Set(relayUrls) { guard networkService.activationAllowed else { return }
if !existing.contains(url) { let targets = allowedRelayList(from: relayUrls)
relays.append(Relay(url: url)) guard !targets.isEmpty else { return }
} if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
if connections[url] == nil { // Defer until Tor is fully ready; avoid queuing connection attempts early
connectToRelay(url) 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) /// Send an event to specified relays (or all if none specified)
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) { 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) ensureConnections(to: targetRelays)
// Add to queue for reliability // Attempt immediate send to relays with active connections; queue the rest
messageQueueLock.lock() var stillPending = Set<String>()
messageQueue.append((event, targetRelays))
messageQueueLock.unlock()
// Attempt immediate send
for relayUrl in targetRelays { for relayUrl in targetRelays {
if let connection = connections[relayUrl] { if let connection = connections[relayUrl] {
sendToRelay(event: event, connection: connection, relayUrl: 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, filter: NostrFilter,
id: String = UUID().uuidString, id: String = UUID().uuidString,
relayUrls: [String]? = nil, 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 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]) let req = NostrRequest.subscribe(id: id, filters: [filter])
do { do {
let encoder = JSONEncoder()
encoder.outputFormatting = .sortedKeys // For consistent output
let message = try encoder.encode(req) let message = try encoder.encode(req)
guard let messageString = String(data: message, encoding: .utf8) else { guard let messageString = String(data: message, encoding: .utf8) else {
SecureLogger.log("❌ Failed to encode subscription request", category: SecureLogger.session, level: .error) SecureLogger.error("❌ Failed to encode subscription request", category: .session)
return return
} }
// SecureLogger.log("📋 Subscription filter JSON: \(messageString.prefix(200))...", // SecureLogger.debug("📋 Subscription filter JSON: \(messageString.prefix(200))...", category: .session)
// category: SecureLogger.session, level: .debug)
// Target specific relays if provided; else all connections // Target specific relays if provided; else default. Filter permanently failed relays.
let urls = relayUrls ?? Self.defaultRelays let baseUrls = relayUrls ?? Self.defaultRelays
ensureConnections(to: urls) let candidateUrls = baseUrls.filter { !isPermanentlyFailed($0) }
let targets: [(String, URLSessionWebSocketTask)] = urls.compactMap { url in let urls = allowedRelayList(from: candidateUrls)
connections[url].map { (url, $0) } // 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 url in candidateUrls {
for (relayUrl, connection) in targets { var map = self.pendingSubscriptions[url] ?? [:]
connection.send(.string(messageString)) { error in map[id] = messageString
if let error = error { self.pendingSubscriptions[url] = map
SecureLogger.log("❌ Failed to send subscription to \(relayUrl): \(error)", }
category: SecureLogger.session, level: .error) // Initialize EOSE tracking if requested
} else { if let onEOSE = onEOSE {
// SecureLogger.log(" Subscription '\(id)' sent to relay: \(relayUrl)", if urls.isEmpty {
// category: SecureLogger.session, level: .debug) onEOSE()
// Subscription sent successfully } 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 Task { @MainActor in
var subs = self.subscriptions[relayUrl] ?? Set<String>() guard let self = self else { return }
subs.insert(id) if let t = self.eoseTrackers[id] {
self.subscriptions[relayUrl] = subs t.timer?.invalidate()
self.eoseTrackers.removeValue(forKey: id)
onEOSE()
}
} }
} }
eoseTrackers[id] = tracker
} }
} }
SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session)
if connections.isEmpty { // Ensure we actually have sockets opening to these relays so queued REQs can flush
SecureLogger.log("⚠️ No relay connections available for subscription", ensureConnections(to: urls)
category: SecureLogger.session, level: .warning) // 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 { } catch {
SecureLogger.log("❌ Failed to encode subscription request: \(error)", SecureLogger.error("❌ Failed to encode subscription request: \(error)", category: .session)
category: SecureLogger.session, level: .error)
} }
} }
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 /// Unsubscribe from a subscription
func unsubscribe(id: String) { func unsubscribe(id: String) {
messageHandlers.removeValue(forKey: id) messageHandlers.removeValue(forKey: id)
// Allow immediate re-subscription by clearing coalescer timestamp
subscribeCoalesce.removeValue(forKey: id)
let req = NostrRequest.close(id: id) let req = NostrRequest.close(id: id)
let message = try? JSONEncoder().encode(req) let message = try? encoder.encode(req)
guard let messageData = message, guard let messageData = message,
let messageString = String(data: messageData, encoding: .utf8) else { return } let messageString = String(data: messageData, encoding: .utf8) else { return }
@@ -195,19 +415,42 @@ class NostrRelayManager: ObservableObject {
// MARK: - Private Methods // MARK: - Private Methods
private func connectToRelay(_ urlString: String) { private func connectToRelay(_ urlString: String) {
// Global network policy gate
guard networkService.activationAllowed else { return }
guard let url = URL(string: urlString) else { guard let url = URL(string: urlString) else {
SecureLogger.log("Invalid relay URL: \(urlString)", category: SecureLogger.session, level: .warning) SecureLogger.warning("Invalid relay URL: \(urlString)", category: .session)
return return
} }
// 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 // Skip if we already have a connection object
if connections[urlString] != nil { if connections[urlString] != nil {
return 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) let task = session.webSocketTask(with: url)
connections[urlString] = task connections[urlString] = task
@@ -220,12 +463,12 @@ class NostrRelayManager: ObservableObject {
task.sendPing { [weak self] error in task.sendPing { [weak self] error in
DispatchQueue.main.async { DispatchQueue.main.async {
if error == nil { if error == nil {
SecureLogger.log("✅ Connected to Nostr relay: \(urlString)", SecureLogger.debug("✅ Connected to Nostr relay: \(urlString)", category: .session)
category: SecureLogger.session, level: .debug)
self?.updateRelayStatus(urlString, isConnected: true) self?.updateRelayStatus(urlString, isConnected: true)
// Flush any pending subscriptions for this relay
self?.flushPendingSubscriptions(for: urlString)
} else { } else {
SecureLogger.log("❌ Failed to connect to Nostr relay \(urlString): \(error?.localizedDescription ?? "Unknown error")", SecureLogger.error("❌ Failed to connect to Nostr relay \(urlString): \(error?.localizedDescription ?? "Unknown error")", category: .session)
category: SecureLogger.session, level: .error)
self?.updateRelayStatus(urlString, isConnected: false, error: error) self?.updateRelayStatus(urlString, isConnected: false, error: error)
// Trigger disconnection handler for proper backoff // Trigger disconnection handler for proper backoff
self?.handleDisconnection(relayUrl: urlString, error: error ?? NSError(domain: "NostrRelay", code: -1, userInfo: nil)) self?.handleDisconnection(relayUrl: urlString, error: error ?? NSError(domain: "NostrRelay", code: -1, userInfo: nil))
@@ -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) { private func receiveMessage(from task: URLSessionWebSocketTask, relayUrl: String) {
task.receive { [weak self] result in task.receive { [weak self] result in
@@ -240,19 +504,12 @@ class NostrRelayManager: ObservableObject {
switch result { switch result {
case .success(let message): case .success(let message):
switch message { // Parse off-main to reduce UI jank, then hop back for state updates
case .string(let text): Task.detached(priority: .utility) {
Task { @MainActor in guard let parsed = ParsedInbound(message) else { return }
self.handleMessage(text, from: relayUrl) 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 // Continue receiving
@@ -268,79 +525,50 @@ class NostrRelayManager: ObservableObject {
} }
} }
private func handleMessage(_ message: String, from relayUrl: String) { // Parsed inbound message type (off-main)
guard let data = message.data(using: .utf8) else { return } // Note: declared at file scope below to avoid MainActor isolation inside this class
// and keep parsing off the main actor.
do {
// Try to decode as an array first // Handle parsed message on MainActor (state updates and handlers)
if let array = try JSONSerialization.jsonObject(with: data) as? [Any], private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) {
array.count >= 2, switch parsed {
let type = array[0] as? String { case .event(let subId, let event):
if event.kind != 1059 {
// Received message from relay SecureLogger.debug("📥 Event kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
}
switch type { if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) {
case "EVENT": self.relays[index].messagesReceived += 1
if array.count >= 3, }
let subId = array[1] as? String, if let handler = self.messageHandlers[subId] {
let eventDict = array[2] as? [String: Any] { handler(event)
} else {
let event = try NostrEvent(from: eventDict) SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session)
}
// Only log non-gift-wrap events to reduce noise case .eose(let subId):
if event.kind != 1059 { if var tracker = eoseTrackers[subId] {
SecureLogger.log("📥 Received Nostr event (kind: \(event.kind)) from relay: \(relayUrl)", tracker.pendingRelays.remove(relayUrl)
category: SecureLogger.session, level: .debug) if tracker.pendingRelays.isEmpty {
} tracker.timer?.invalidate()
eoseTrackers.removeValue(forKey: subId)
DispatchQueue.main.async { tracker.callback()
// Update relay stats } else {
if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) { eoseTrackers[subId] = tracker
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("✅ Event accepted id=\(eventId.prefix(16))... by relay: \(relayUrl)",
category: SecureLogger.session, level: .debug)
} else {
let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil
SecureLogger.log("📮 Event \(eventId.prefix(16))... rejected by relay: \(reason)",
category: SecureLogger.session, level: isGiftWrap ? .warning : .error)
}
}
case "NOTICE":
if array.count >= 2 {
// Server notice received
}
default:
break // Unknown message type
} }
} }
} catch { case .ok(let eventId, let success, let reason):
SecureLogger.log("Failed to parse Nostr message: \(error)", category: SecureLogger.session, level: .error) 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) let req = NostrRequest.event(event)
do { do {
let encoder = JSONEncoder()
encoder.outputFormatting = .sortedKeys
let data = try encoder.encode(req) let data = try encoder.encode(req)
let message = String(data: data, encoding: .utf8) ?? "" let message = String(data: data, encoding: .utf8) ?? ""
SecureLogger.log("📤 Sending Nostr event (kind: \(event.kind)) to relay: \(relayUrl)", SecureLogger.debug("📤 Send kind=\(event.kind) id=\(event.id.prefix(16)) relay=\(relayUrl)", category: .session)
category: SecureLogger.session, level: .debug)
connection.send(.string(message)) { [weak self] error in connection.send(.string(message)) { [weak self] error in
DispatchQueue.main.async { DispatchQueue.main.async {
if let error = error { if let error = error {
SecureLogger.log("❌ Failed to send event to \(relayUrl): \(error)", SecureLogger.error("❌ Failed to send event to \(relayUrl): \(error)", category: .session)
category: SecureLogger.session, level: .error)
} else { } else {
// SecureLogger.log(" Event sent to relay: \(relayUrl)", // SecureLogger.debug(" Event sent to relay: \(relayUrl)", category: .session)
// category: SecureLogger.session, level: .debug)
// Update relay stats // Update relay stats
if let index = self?.relays.firstIndex(where: { $0.url == relayUrl }) { if let index = self?.relays.firstIndex(where: { $0.url == relayUrl }) {
self?.relays[index].messagesSent += 1 self?.relays[index].messagesSent += 1
@@ -372,7 +595,7 @@ class NostrRelayManager: ObservableObject {
} }
} }
} catch { } catch {
SecureLogger.log("Failed to encode event: \(error)", category: SecureLogger.session, level: .error) SecureLogger.error("Failed to encode event: \(error)", category: .session)
} }
} }
@@ -389,6 +612,10 @@ class NostrRelayManager: ObservableObject {
} }
} }
updateConnectionStatus() updateConnectionStatus()
// If we just connected to this relay, flush any queued sends targeting it
if isConnected {
flushMessageQueue(for: url)
}
} }
private func updateConnectionStatus() { private func updateConnectionStatus() {
@@ -396,22 +623,32 @@ class NostrRelayManager: ObservableObject {
} }
private func handleDisconnection(relayUrl: String, error: Error) { 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) connections.removeValue(forKey: relayUrl)
subscriptions.removeValue(forKey: relayUrl) subscriptions.removeValue(forKey: relayUrl)
updateRelayStatus(relayUrl, isConnected: false, error: error) 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 errorDescription = error.localizedDescription.lowercased()
let ns = error as NSError
if errorDescription.contains("hostname could not be found") || if errorDescription.contains("hostname could not be found") ||
errorDescription.contains("dns") { errorDescription.contains("dns") ||
// Only log once for DNS failures (ns.domain == NSURLErrorDomain && ns.code == NSURLErrorBadServerResponse) {
if relays.first(where: { $0.url == relayUrl })?.lastError == nil { if relays.first(where: { $0.url == relayUrl })?.lastError == nil {
SecureLogger.log("Nostr relay 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 }) { if let index = relays.firstIndex(where: { $0.url == relayUrl }) {
relays[index].lastError = error relays[index].lastError = error
relays[index].reconnectAttempts = maxReconnectAttempts
relays[index].nextReconnectTime = nil
} }
pendingSubscriptions[relayUrl] = nil
return return
} }
@@ -422,8 +659,7 @@ class NostrRelayManager: ObservableObject {
// Stop attempting after max attempts // Stop attempting after max attempts
if relays[index].reconnectAttempts >= maxReconnectAttempts { if relays[index].reconnectAttempts >= maxReconnectAttempts {
SecureLogger.log("Max reconnection attempts (\(maxReconnectAttempts)) reached for \(relayUrl)", SecureLogger.warning("Max reconnection attempts (\(maxReconnectAttempts)) reached for \(relayUrl)", category: .session)
category: SecureLogger.session, level: .warning)
return return
} }
@@ -438,9 +674,11 @@ class NostrRelayManager: ObservableObject {
// Schedule reconnection with exponential backoff // Schedule reconnection with exponential backoff
let gen = connectionGeneration
DispatchQueue.main.asyncAfter(deadline: .now() + backoffInterval) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + backoffInterval) { [weak self] in
guard let self = self else { return } 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) // Check if we should still reconnect (relay might have been removed)
if self.relays.contains(where: { $0.url == relayUrl }) { if self.relays.contains(where: { $0.url == relayUrl }) {
self.connectToRelay(relayUrl) self.connectToRelay(relayUrl)
@@ -481,6 +719,8 @@ class NostrRelayManager: ObservableObject {
/// Reset all relay connections /// Reset all relay connections
func resetAllConnections() { func resetAllConnections() {
disconnect() disconnect()
// New generation begins now
connectionGeneration &+= 1
// Reset all relay states // Reset all relay states
for index in relays.indices { for index in relays.indices {
@@ -492,6 +732,81 @@ class NostrRelayManager: ObservableObject {
// Reconnect // Reconnect
connect() 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 // MARK: - Nostr Protocol Types
@@ -568,7 +883,7 @@ struct NostrFilter: Encodable {
filter.kinds = [1059] // Gift wrap kind filter.kinds = [1059] // Gift wrap kind
filter.since = since?.timeIntervalSince1970.toInt() filter.since = since?.timeIntervalSince1970.toInt()
filter.tagFilters = ["p": [pubkey]] filter.tagFilters = ["p": [pubkey]]
filter.limit = 100 // Add a reasonable limit filter.limit = TransportConfig.nostrRelayDefaultFetchLimit // reasonable limit
return filter return filter
} }
@@ -581,6 +896,26 @@ struct NostrFilter: Encodable {
filter.limit = limit filter.limit = limit
return filter 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 // Dynamic coding key for tag filters
+9 -10
View File
@@ -40,23 +40,23 @@ extension Data {
extension Data { extension Data {
// MARK: Writing // MARK: Writing
mutating func appendUInt8(_ value: UInt8) { @inlinable mutating func appendUInt8(_ value: UInt8) {
self.append(value) self.append(value)
} }
mutating func appendUInt16(_ value: UInt16) { @inlinable mutating func appendUInt16(_ value: UInt16) {
self.append(UInt8((value >> 8) & 0xFF)) self.append(UInt8((value >> 8) & 0xFF))
self.append(UInt8(value & 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 >> 24) & 0xFF))
self.append(UInt8((value >> 16) & 0xFF)) self.append(UInt8((value >> 16) & 0xFF))
self.append(UInt8((value >> 8) & 0xFF)) self.append(UInt8((value >> 8) & 0xFF))
self.append(UInt8(value & 0xFF)) self.append(UInt8(value & 0xFF))
} }
mutating func appendUInt64(_ value: UInt64) { @inlinable mutating func appendUInt64(_ value: UInt64) {
for i in (0..<8).reversed() { for i in (0..<8).reversed() {
self.append(UInt8((value >> (i * 8)) & 0xFF)) self.append(UInt8((value >> (i * 8)) & 0xFF))
} }
@@ -113,21 +113,21 @@ extension Data {
// MARK: Reading // 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 } guard offset >= 0 && offset < self.count else { return nil }
let value = self[offset] let value = self[offset]
offset += 1 offset += 1
return value 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 } guard offset + 2 <= self.count else { return nil }
let value = UInt16(self[offset]) << 8 | UInt16(self[offset + 1]) let value = UInt16(self[offset]) << 8 | UInt16(self[offset + 1])
offset += 2 offset += 2
return value 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 } guard offset + 4 <= self.count else { return nil }
let value = UInt32(self[offset]) << 24 | let value = UInt32(self[offset]) << 24 |
UInt32(self[offset + 1]) << 16 | UInt32(self[offset + 1]) << 16 |
@@ -137,7 +137,7 @@ extension Data {
return value 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 } guard offset + 8 <= self.count else { return nil }
var value: UInt64 = 0 var value: UInt64 = 0
for i in 0..<8 { for i in 0..<8 {
@@ -197,7 +197,7 @@ extension Data {
offset += 16 offset += 16
// Convert 16 bytes to UUID string format // 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 // Insert hyphens at proper positions: 8-4-4-4-12
var result = "" var result = ""
@@ -220,4 +220,3 @@ extension Data {
return data return data
} }
} }
+97 -355
View File
@@ -139,6 +139,11 @@ struct BinaryProtocol {
} }
// Header // Header
// Reserve capacity to reduce reallocations. Estimate base size conservatively.
// header(13) + sender(8) + opt recipient(8) + opt originalSize(2) + payload + opt signature(64) + up to 255 pad
let estimatedPayload = payload.count + (isCompressed ? 2 : 0)
let estimated = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize) + estimatedPayload + (packet.signature == nil ? 0 : signatureSize) + 255
data.reserveCapacity(estimated)
data.append(packet.version) data.append(packet.version)
data.append(packet.type) data.append(packet.type)
data.append(packet.ttl) data.append(packet.ttl)
@@ -222,367 +227,104 @@ struct BinaryProtocol {
// Core decoding implementation used by decode(_:) with and without padding removal // Core decoding implementation used by decode(_:) with and without padding removal
private static func decodeCore(_ raw: Data) -> BitchatPacket? { private static func decodeCore(_ raw: Data) -> BitchatPacket? {
// Minimum size check: header + senderID // Minimum size: header + senderID
guard raw.count >= headerSize + senderIDSize else { guard raw.count >= headerSize + senderIDSize else { return nil }
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
)
}
}
// Binary encoding for BitchatMessage return raw.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> BitchatPacket? in
extension BitchatMessage { guard let base = buf.baseAddress else { return nil }
func toBinaryPayload() -> Data? { var offset = 0
var data = Data() func require(_ n: Int) -> Bool { offset + n <= buf.count }
// Read single byte
// Message format: func read8() -> UInt8? {
// - Flags: 1 byte (bit 0: isRelay, bit 1: isPrivate, bit 2: hasOriginalSender, bit 3: hasRecipientNickname, bit 4: hasSenderPeerID, bit 5: hasMentions) guard require(1) else { return nil }
// - Timestamp: 8 bytes (seconds since epoch) let v = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self).pointee
// - ID length: 1 byte offset += 1
// - ID: variable return v
// - 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)
}
} }
} // Read big-endian 16-bit
func read16() -> UInt16? {
guard require(2) else { return nil }
return data let p = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
} let v = (UInt16(p[0]) << 8) | UInt16(p[1])
offset += 2
static func fromBinaryPayload(_ data: Data) -> BitchatMessage? { return v
// 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
} }
} // Copy N bytes into Data
func readData(_ n: Int) -> Data? {
var recipientNickname: String? guard require(n) else { return nil }
if hasRecipientNickname && offset < dataCopy.count { let ptr = base.advanced(by: offset)
let length = Int(dataCopy[offset]); offset += 1 let d = Data(bytes: ptr, count: n)
if offset + length <= dataCopy.count { offset += n
recipientNickname = String(data: dataCopy[offset..<offset+length], encoding: .utf8) return d
offset += length
} }
}
// Version
var senderPeerID: String? guard let version = read8(), version == 1 else { return nil }
if hasSenderPeerID && offset < dataCopy.count { guard let type = read8() else { return nil }
let length = Int(dataCopy[offset]); offset += 1 guard let ttl = read8() else { return nil }
if offset + length <= dataCopy.count {
senderPeerID = String(data: dataCopy[offset..<offset+length], encoding: .utf8) // Timestamp 8 bytes BE
offset += length guard require(8) else { return nil }
var ts: UInt64 = 0
for _ in 0..<8 {
guard let b = read8() else { return nil }
ts = (ts << 8) | UInt64(b)
} }
}
// Flags
// Mentions array guard let flags = read8() else { return nil }
var mentions: [String]? let hasRecipient = (flags & Flags.hasRecipient) != 0
if hasMentions && offset < dataCopy.count { let hasSignature = (flags & Flags.hasSignature) != 0
let mentionCount = Int(dataCopy[offset]); offset += 1 let isCompressed = (flags & Flags.isCompressed) != 0
if mentionCount > 0 {
mentions = [] // Payload length
for _ in 0..<mentionCount { guard let payloadLen = read16(), payloadLen <= 65535 else { return nil }
if offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1 // SenderID
if offset + length <= dataCopy.count { guard let senderID = readData(senderIDSize) else { return nil }
if let mention = String(data: dataCopy[offset..<offset+length], encoding: .utf8) {
mentions?.append(mention) // Recipient
} var recipientID: Data? = nil
offset += length if hasRecipient {
} recipientID = readData(recipientIDSize)
} if recipientID == nil { return nil }
}
} }
// Payload
let payload: Data
if isCompressed {
// Need original size (2 bytes)
guard let origSize16 = read16() else { return nil }
let originalSize = Int(origSize16)
guard originalSize >= 0 && originalSize <= 1_048_576 else { return nil }
let compSize = Int(payloadLen) - 2
guard compSize >= 0, let compressed = readData(compSize) else { return nil }
guard let decompressed = CompressionUtil.decompress(compressed, originalSize: originalSize),
decompressed.count == originalSize else { return nil }
payload = decompressed
} else {
guard let p = readData(Int(payloadLen)) else { return nil }
payload = p
}
// Signature
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: ts,
payload: payload,
signature: signature,
ttl: ttl
)
} }
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
} }
} }
+21 -350
View File
@@ -59,61 +59,7 @@
/// ///
import Foundation import Foundation
import CryptoKit import CoreBluetooth
// 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
}
}
// MARK: - Message Types // MARK: - Message Types
@@ -125,6 +71,7 @@ enum MessageType: UInt8 {
case announce = 0x01 // "I'm here" with nickname case announce = 0x01 // "I'm here" with nickname
case message = 0x02 // Public chat message case message = 0x02 // Public chat message
case leave = 0x03 // "I'm leaving" case leave = 0x03 // "I'm leaving"
case requestSync = 0x21 // GCS filter-based sync request (local-only)
// Noise encryption // Noise encryption
case noiseHandshake = 0x10 // Handshake (init or response determined by payload) case noiseHandshake = 0x10 // Handshake (init or response determined by payload)
@@ -138,6 +85,7 @@ enum MessageType: UInt8 {
case .announce: return "announce" case .announce: return "announce"
case .message: return "message" case .message: return "message"
case .leave: return "leave" case .leave: return "leave"
case .requestSync: return "requestSync"
case .noiseHandshake: return "noiseHandshake" case .noiseHandshake: return "noiseHandshake"
case .noiseEncrypted: return "noiseEncrypted" case .noiseEncrypted: return "noiseEncrypted"
case .fragment: return "fragment" case .fragment: return "fragment"
@@ -155,12 +103,17 @@ enum NoisePayloadType: UInt8 {
case privateMessage = 0x01 // Private chat message case privateMessage = 0x01 // Private chat message
case readReceipt = 0x02 // Message was read case readReceipt = 0x02 // Message was read
case delivered = 0x03 // Message was delivered case delivered = 0x03 // Message was delivered
// Verification (QR-based OOB binding)
case verifyChallenge = 0x10 // Verification challenge
case verifyResponse = 0x11 // Verification response
var description: String { var description: String {
switch self { switch self {
case .privateMessage: return "privateMessage" case .privateMessage: return "privateMessage"
case .readReceipt: return "readReceipt" case .readReceipt: return "readReceipt"
case .delivered: return "delivered" case .delivered: return "delivered"
case .verifyChallenge: return "verifyChallenge"
case .verifyResponse: return "verifyResponse"
} }
} }
} }
@@ -176,193 +129,10 @@ enum LazyHandshakeState {
case failed(Error) // Handshake failed case failed(Error) // Handshake failed
} }
// MARK: - Special Recipients (removed)
// Previously defined broadcast identifiers were unused; removed for simplicity.
// 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: - Delivery Acknowledgments (removed)
// Legacy DeliveryAck structures are no longer used; delivery status flows via Noise payloads.
// 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)
}
}
// PeerIdentityBinding removed (unused).
// MARK: - Delivery Status // MARK: - Delivery Status
// Delivery status for messages // Delivery status for messages
enum DeliveryStatus: Codable, Equatable { enum DeliveryStatus: Codable, Equatable, Hashable {
case sending case sending
case sent // Left our device case sent // Left our device
case delivered(to: String, at: Date) // Confirmed by recipient case delivered(to: String, at: Date) // Confirmed by recipient
@@ -388,90 +158,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 // MARK: - Delegate Protocol
protocol BitchatDelegate: AnyObject { protocol BitchatDelegate: AnyObject {
func didReceiveMessage(_ message: BitchatMessage) func didReceiveMessage(_ message: BitchatMessage)
func didConnectToPeer(_ peerID: String) func didConnectToPeer(_ peerID: PeerID)
func didDisconnectFromPeer(_ peerID: String) func didDisconnectFromPeer(_ peerID: PeerID)
func didUpdatePeerList(_ peers: [String]) func didUpdatePeerList(_ peers: [PeerID])
// Optional method to check if a fingerprint belongs to a favorite peer // Optional method to check if a fingerprint belongs to a favorite peer
func isFavorite(fingerprint: String) -> Bool func isFavorite(fingerprint: String) -> Bool
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus)
// Low-level events for better separation of concerns // Low-level events for better separation of concerns
func didReceiveNoisePayload(from peerID: String, type: NoisePayloadType, payload: Data, timestamp: Date) func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date)
// Bluetooth state updates for user notifications
func didUpdateBluetoothState(_ state: CBManagerState)
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date)
} }
// Provide default implementation to make it effectively optional // Provide default implementation to make it effectively optional
@@ -484,45 +189,11 @@ extension BitchatDelegate {
// Default empty implementation // 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 // 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) {
// Default empty implementation // 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)
}
}
+85
View File
@@ -10,6 +10,14 @@ enum Geohash {
return map 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. /// Encodes the provided coordinates into a geohash string.
/// - Parameters: /// - Parameters:
/// - latitude: Latitude in degrees (-90...90) /// - latitude: Latitude in degrees (-90...90)
@@ -87,4 +95,81 @@ enum Geohash {
let lon = (lonInterval.0 + lonInterval.1) / 2 let lon = (lonInterval.0 + lonInterval.1) / 2
return (lat, lon) return (lat, lon)
} }
/// Decodes a geohash into its latitude and longitude bounds.
/// - Parameter geohash: Base32 geohash string.
/// - Returns: (latMin, latMax, lonMin, lonMax)
static func decodeBounds(_ geohash: String) -> (latMin: Double, latMax: Double, lonMin: Double, lonMax: Double) {
var latInterval: (Double, Double) = (-90.0, 90.0)
var lonInterval: (Double, Double) = (-180.0, 180.0)
var isEven = true
for ch in geohash.lowercased() {
guard let cd = base32Map[ch] else { continue }
for mask in [16, 8, 4, 2, 1] {
if isEven {
let mid = (lonInterval.0 + lonInterval.1) / 2
if (cd & mask) != 0 { lonInterval.0 = mid } else { lonInterval.1 = mid }
} else {
let mid = (latInterval.0 + latInterval.1) / 2
if (cd & mask) != 0 { latInterval.0 = mid } else { latInterval.1 = mid }
}
isEven.toggle()
}
}
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)
}
}
} }
+18 -6
View File
@@ -2,6 +2,7 @@ import Foundation
/// Levels of location channels mapped to geohash precisions. /// Levels of location channels mapped to geohash precisions.
enum GeohashChannelLevel: CaseIterable, Codable, Equatable { enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
case building
case block case block
case neighborhood case neighborhood
case city case city
@@ -11,6 +12,7 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
/// Geohash length used for this level. /// Geohash length used for this level.
var precision: Int { var precision: Int {
switch self { switch self {
case .building: return 8
case .block: return 7 case .block: return 7
case .neighborhood: return 6 case .neighborhood: return 6
case .city: return 5 case .city: return 5
@@ -21,20 +23,28 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
var displayName: String { var displayName: String {
switch self { switch self {
case .block: return "Block" case .building:
case .neighborhood: return "Neighborhood" return String(localized: "location_levels.building", comment: "Name for building-level location channel")
case .city: return "City" case .block:
case .province: return "Province" return String(localized: "location_levels.block", comment: "Name for block-level location channel")
case .region: return "Region" 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 // Backward-compatible Codable for renamed cases
extension GeohashChannelLevel { extension GeohashChannelLevel {
init(from decoder: Decoder) throws { init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer() let container = try decoder.singleValueContainer()
if let raw = try? container.decode(String.self) { if let raw = try? container.decode(String.self) {
switch raw { switch raw {
case "building": self = .building
case "block": self = .block case "block": self = .block
case "neighborhood": self = .neighborhood case "neighborhood": self = .neighborhood
case "city": self = .city case "city": self = .city
@@ -46,6 +56,7 @@ extension GeohashChannelLevel {
} }
} else if let precision = try? container.decode(Int.self) { } else if let precision = try? container.decode(Int.self) {
switch precision { switch precision {
case 8: self = .building
case 7: self = .block case 7: self = .block
case 6: self = .neighborhood case 6: self = .neighborhood
case 5: self = .city case 5: self = .city
@@ -61,6 +72,7 @@ extension GeohashChannelLevel {
func encode(to encoder: Encoder) throws { func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer() var container = encoder.singleValueContainer()
switch self { switch self {
case .building: try container.encode("building")
case .block: try container.encode("block") case .block: try container.encode("block")
case .neighborhood: try container.encode("neighborhood") case .neighborhood: try container.encode("neighborhood")
case .city: try container.encode("city") case .city: try container.encode("city")
+3
View File
@@ -15,6 +15,8 @@ struct AnnouncementPacket {
func encode() -> Data? { func encode() -> Data? {
var data = 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 // TLV for nickname
guard let nicknameData = nickname.data(using: .utf8), nicknameData.count <= 255 else { return nil } guard let nicknameData = nickname.data(using: .utf8), nicknameData.count <= 255 else { return nil }
@@ -88,6 +90,7 @@ struct PrivateMessagePacket {
func encode() -> Data? { func encode() -> Data? {
var data = Data() var data = Data()
data.reserveCapacity(2 + min(messageID.count, 255) + 2 + min(content.count, 255))
// TLV for messageID // TLV for messageID
guard let messageIDData = messageID.data(using: .utf8), messageIDData.count <= 255 else { return nil } 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))
}
}
+4 -4
View File
@@ -9,12 +9,12 @@
import Foundation import Foundation
/// Manages autocomplete functionality for chat /// Manages autocomplete functionality for chat
class AutocompleteService { final class AutocompleteService {
private let mentionRegex = try? NSRegularExpression(pattern: "@([\\p{L}0-9_]*)$", options: []) private let mentionRegex = try? NSRegularExpression(pattern: "@([\\p{L}0-9_]*)$", options: [])
private let commandRegex = try? NSRegularExpression(pattern: "^/([a-z]*)$", options: []) private let commandRegex = try? NSRegularExpression(pattern: "^/([a-z]*)$", options: [])
private let commands = [ private let commands = [
"/msg", "/who", "/clear", "/help", "/msg", "/who", "/clear",
"/hug", "/slap", "/fav", "/unfav", "/hug", "/slap", "/fav", "/unfav",
"/block", "/unblock" "/block", "/unblock"
] ]
@@ -95,10 +95,10 @@ class AutocompleteService {
private func needsArgument(command: String) -> Bool { private func needsArgument(command: String) -> Bool {
switch command { switch command {
case "/who", "/clear", "/help": case "/who", "/clear":
return false return false
default: default:
return true return true
} }
} }
} }
File diff suppressed because it is too large Load Diff
+56 -45
View File
@@ -17,13 +17,15 @@ enum CommandResult {
/// Processes chat commands in a focused, efficient way /// Processes chat commands in a focused, efficient way
@MainActor @MainActor
class CommandProcessor { final class CommandProcessor {
weak var chatViewModel: ChatViewModel? weak var chatViewModel: ChatViewModel?
weak var meshService: Transport? weak var meshService: Transport?
private let identityManager: SecureIdentityStateManagerProtocol
init(chatViewModel: ChatViewModel? = nil, meshService: Transport? = nil) { init(chatViewModel: ChatViewModel? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {
self.chatViewModel = chatViewModel self.chatViewModel = chatViewModel
self.meshService = meshService self.meshService = meshService
self.identityManager = identityManager
} }
/// Process a command string /// Process a command string
@@ -33,6 +35,15 @@ class CommandProcessor {
guard let cmd = parts.first else { return .error(message: "Invalid command") } guard let cmd = parts.first else { return .error(message: "Invalid command") }
let args = parts.count > 1 ? String(parts[1]) : "" let args = parts.count > 1 ? String(parts[1]) : ""
// Geohash context: disable favoriting in public geohash or GeoDM
let inGeoPublic: Bool = {
switch LocationChannelManager.shared.selectedChannel {
case .mesh: return false
case .location: return true
}
}()
let inGeoDM = chatViewModel?.selectedPrivateChatPeer?.isGeoDM == true
switch cmd { switch cmd {
case "/m", "/msg": case "/m", "/msg":
return handleMessage(args) return handleMessage(args)
@@ -41,24 +52,24 @@ class CommandProcessor {
case "/clear": case "/clear":
return handleClear() return handleClear()
case "/hug": case "/hug":
return handleEmote(args, action: "hugs", emoji: "🫂") return handleEmote(args, command: "hug", action: "hugs", emoji: "🫂")
case "/slap": case "/slap":
return handleEmote(args, action: "slaps", emoji: "🐟", suffix: " around a bit with a large trout") return handleEmote(args, command: "slap", action: "slaps", emoji: "🐟", suffix: " around a bit with a large trout")
case "/block": case "/block":
return handleBlock(args) return handleBlock(args)
case "/unblock": case "/unblock":
return handleUnblock(args) return handleUnblock(args)
case "/fav": case "/fav":
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
return handleFavorite(args, add: true) return handleFavorite(args, add: true)
case "/unfav": case "/unfav":
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
return handleFavorite(args, add: false) return handleFavorite(args, add: false)
case "/help", "/h":
return handleHelp()
default: default:
return .error(message: "unknown command: \(cmd)") return .error(message: "unknown command: \(cmd)")
} }
} }
// MARK: - Command Handlers // MARK: - Command Handlers
private func handleMessage(_ args: String) -> CommandResult { private func handleMessage(_ args: String) -> CommandResult {
@@ -85,34 +96,49 @@ class CommandProcessor {
} }
private func handleWho() -> CommandResult { private func handleWho() -> CommandResult {
guard let peers = meshService?.getPeerNicknames(), !peers.isEmpty else { // Show geohash participants when in a geohash channel; otherwise mesh peers
return .success(message: "no one else is online right now") 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? chatViewModel?.idBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
let people = vm.visibleGeohashPeople().filter { person in
if let me = myHex { return person.id.lowercased() != me }
return true
}
let names = people.map { $0.displayName }
if names.isEmpty { return .success(message: "no one else is online right now") }
return .success(message: "online: " + names.sorted().joined(separator: ", "))
case .mesh:
// Mesh context: show connected peer nicknames
guard let peers = meshService?.getPeerNicknames(), !peers.isEmpty else {
return .success(message: "no one else is online right now")
}
let onlineList = peers.values.sorted().joined(separator: ", ")
return .success(message: "online: \(onlineList)")
} }
let onlineList = peers.values.sorted().joined(separator: ", ")
return .success(message: "online: \(onlineList)")
} }
private func handleClear() -> CommandResult { private func handleClear() -> CommandResult {
if let peerID = chatViewModel?.selectedPrivateChatPeer { if let peerID = chatViewModel?.selectedPrivateChatPeer {
chatViewModel?.privateChats[peerID]?.removeAll() chatViewModel?.privateChats[peerID]?.removeAll()
} else { } else {
chatViewModel?.messages.removeAll() chatViewModel?.clearCurrentPublicTimeline()
} }
return .handled return .handled
} }
private func handleEmote(_ args: String, action: String, emoji: String, suffix: String = "") -> CommandResult { private func handleEmote(_ args: String, command: String, action: String, emoji: String, suffix: String = "") -> CommandResult {
let targetName = args.trimmingCharacters(in: .whitespaces) let targetName = args.trimmingCharacters(in: .whitespaces)
guard !targetName.isEmpty else { guard !targetName.isEmpty else {
return .error(message: "usage: /\(action) <nickname>") return .error(message: "usage: /\(command) <nickname>")
} }
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
guard let targetPeerID = chatViewModel?.getPeerIDForNickname(nickname), guard let targetPeerID = chatViewModel?.getPeerIDForNickname(nickname),
let myNickname = chatViewModel?.nickname else { let myNickname = chatViewModel?.nickname else {
return .error(message: "cannot \(action) \(nickname): not found") return .error(message: "cannot \(command) \(nickname): not found")
} }
let emoteContent = "* \(emoji) \(myNickname) \(action) \(nickname)\(suffix) *" let emoteContent = "* \(emoji) \(myNickname) \(action) \(nickname)\(suffix) *"
@@ -121,8 +147,8 @@ class CommandProcessor {
// In private chat // In private chat
if let peerNickname = meshService?.peerNickname(peerID: targetPeerID) { if let peerNickname = meshService?.peerNickname(peerID: targetPeerID) {
let personalMessage = "* \(emoji) \(myNickname) \(action) you\(suffix) *" let personalMessage = "* \(emoji) \(myNickname) \(action) you\(suffix) *"
meshService?.sendPrivateMessage(personalMessage, to: targetPeerID, meshService?.sendPrivateMessage(personalMessage, to: targetPeerID,
recipientNickname: peerNickname, recipientNickname: peerNickname,
messageID: UUID().uuidString) messageID: UUID().uuidString)
// Also add a local system message so the sender sees a natural-language confirmation // Also add a local system message so the sender sees a natural-language confirmation
let pastAction: String = { let pastAction: String = {
@@ -162,7 +188,7 @@ class CommandProcessor {
} }
// Geohash blocked names (prefer visible display names; fallback to #suffix) // Geohash blocked names (prefer visible display names; fallback to #suffix)
let geoBlocked = Array(SecureIdentityStateManager.shared.getBlockedNostrPubkeys()) let geoBlocked = Array(identityManager.getBlockedNostrPubkeys())
var geoNames: [String] = [] var geoNames: [String] = []
if let vm = chatViewModel { if let vm = chatViewModel {
let visible = vm.visibleGeohashPeople() let visible = vm.visibleGeohashPeople()
@@ -186,14 +212,14 @@ class CommandProcessor {
if let peerID = chatViewModel?.getPeerIDForNickname(nickname), if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
let fingerprint = meshService?.getFingerprint(for: peerID) { let fingerprint = meshService?.getFingerprint(for: peerID) {
if SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) { if identityManager.isBlocked(fingerprint: fingerprint) {
return .success(message: "\(nickname) is already blocked") return .success(message: "\(nickname) is already blocked")
} }
// Block the user (mesh/noise identity) // Block the user (mesh/noise identity)
if var identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprint) { if var identity = identityManager.getSocialIdentity(for: fingerprint) {
identity.isBlocked = true identity.isBlocked = true
identity.isFavorite = false identity.isFavorite = false
SecureIdentityStateManager.shared.updateSocialIdentity(identity) identityManager.updateSocialIdentity(identity)
} else { } else {
let blockedIdentity = SocialIdentity( let blockedIdentity = SocialIdentity(
fingerprint: fingerprint, fingerprint: fingerprint,
@@ -204,16 +230,16 @@ class CommandProcessor {
isBlocked: true, isBlocked: true,
notes: nil notes: nil
) )
SecureIdentityStateManager.shared.updateSocialIdentity(blockedIdentity) identityManager.updateSocialIdentity(blockedIdentity)
} }
return .success(message: "blocked \(nickname). you will no longer receive messages from them") return .success(message: "blocked \(nickname). you will no longer receive messages from them")
} }
// Mesh lookup failed; try geohash (Nostr) participant by display name // Mesh lookup failed; try geohash (Nostr) participant by display name
if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) { if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) {
if SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: pub) { if identityManager.isNostrBlocked(pubkeyHexLowercased: pub) {
return .success(message: "\(nickname) is already blocked") return .success(message: "\(nickname) is already blocked")
} }
SecureIdentityStateManager.shared.setNostrBlocked(pub, isBlocked: true) identityManager.setNostrBlocked(pub, isBlocked: true)
return .success(message: "blocked \(nickname) in geohash chats") return .success(message: "blocked \(nickname) in geohash chats")
} }
@@ -230,18 +256,18 @@ class CommandProcessor {
if let peerID = chatViewModel?.getPeerIDForNickname(nickname), if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
let fingerprint = meshService?.getFingerprint(for: peerID) { let fingerprint = meshService?.getFingerprint(for: peerID) {
if !SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) { if !identityManager.isBlocked(fingerprint: fingerprint) {
return .success(message: "\(nickname) is not blocked") return .success(message: "\(nickname) is not blocked")
} }
SecureIdentityStateManager.shared.setBlocked(fingerprint, isBlocked: false) identityManager.setBlocked(fingerprint, isBlocked: false)
return .success(message: "unblocked \(nickname)") return .success(message: "unblocked \(nickname)")
} }
// Try geohash unblock // Try geohash unblock
if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) { if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) {
if !SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: pub) { if !identityManager.isNostrBlocked(pubkeyHexLowercased: pub) {
return .success(message: "\(nickname) is not blocked") return .success(message: "\(nickname) is not blocked")
} }
SecureIdentityStateManager.shared.setNostrBlocked(pub, isBlocked: false) identityManager.setNostrBlocked(pub, isBlocked: false)
return .success(message: "unblocked \(nickname) in geohash chats") return .success(message: "unblocked \(nickname) in geohash chats")
} }
return .error(message: "cannot unblock \(nickname): not found") return .error(message: "cannot unblock \(nickname): not found")
@@ -256,7 +282,7 @@ class CommandProcessor {
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname), guard let peerID = chatViewModel?.getPeerIDForNickname(nickname),
let noisePublicKey = Data(hexString: peerID) else { let noisePublicKey = Data(hexString: peerID.id) else {
return .error(message: "can't find peer: \(nickname)") return .error(message: "can't find peer: \(nickname)")
} }
@@ -282,19 +308,4 @@ class CommandProcessor {
} }
} }
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 Foundation
import Combine import Combine
/// Manages persistent favorite relationships between peers /// Manages persistent favorite relationships between peers
@MainActor @MainActor
class FavoritesPersistenceService: ObservableObject { final class FavoritesPersistenceService: ObservableObject {
struct FavoriteRelationship: Codable { struct FavoriteRelationship: Codable {
let peerNoisePublicKey: Data let peerNoisePublicKey: Data
@@ -13,14 +14,19 @@ class FavoritesPersistenceService: ObservableObject {
let theyFavoritedUs: Bool let theyFavoritedUs: Bool
let favoritedAt: Date let favoritedAt: Date
let lastUpdated: Date let lastUpdated: Date
// Track what we last sent as OUR npub to this peer, to avoid resending unless it changes
// Note: we do not track which npub we last sent to them; sending happens only on favorite toggle
var isMutual: Bool { var isMutual: Bool {
isFavorite && theyFavoritedUs isFavorite && theyFavoritedUs
} }
} }
// We intentionally do not track when we last sent our npub; sending happens only on favorite toggle.
private static let storageKey = "chat.bitchat.favorites" private static let storageKey = "chat.bitchat.favorites"
private static let keychainService = "chat.bitchat.favorites" private static let keychainService = "chat.bitchat.favorites"
private let keychain: KeychainHelperProtocol
@Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship @Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship
@Published private(set) var mutualFavorites: Set<Data> = [] @Published private(set) var mutualFavorites: Set<Data> = []
@@ -30,7 +36,8 @@ class FavoritesPersistenceService: ObservableObject {
static let shared = FavoritesPersistenceService() static let shared = FavoritesPersistenceService()
private init() { init(keychain: KeychainHelperProtocol = KeychainHelper()) {
self.keychain = keychain
loadFavorites() loadFavorites()
// Update mutual favorites when favorites change // Update mutual favorites when favorites change
@@ -47,8 +54,7 @@ class FavoritesPersistenceService: ObservableObject {
peerNostrPublicKey: String? = nil, peerNostrPublicKey: String? = nil,
peerNickname: String peerNickname: String
) { ) {
SecureLogger.log("⭐️ Adding favorite: \(peerNickname) (\(peerNoisePublicKey.hexEncodedString()))", SecureLogger.info("⭐️ Adding favorite: \(peerNickname) (\(peerNoisePublicKey.hexEncodedString()))", category: .session)
category: SecureLogger.session, level: .info)
let existing = favorites[peerNoisePublicKey] let existing = favorites[peerNoisePublicKey]
@@ -64,8 +70,7 @@ class FavoritesPersistenceService: ObservableObject {
// Log if this creates a mutual favorite // Log if this creates a mutual favorite
if relationship.isMutual { if relationship.isMutual {
SecureLogger.log("💕 Mutual favorite relationship established with \(peerNickname)!", SecureLogger.info("💕 Mutual favorite relationship established with \(peerNickname)!", category: .session)
category: SecureLogger.session, level: .info)
} }
favorites[peerNoisePublicKey] = relationship favorites[peerNoisePublicKey] = relationship
@@ -83,8 +88,7 @@ class FavoritesPersistenceService: ObservableObject {
func removeFavorite(peerNoisePublicKey: Data) { func removeFavorite(peerNoisePublicKey: Data) {
guard let existing = favorites[peerNoisePublicKey] else { return } guard let existing = favorites[peerNoisePublicKey] else { return }
SecureLogger.log("⭐️ Removing favorite: \(existing.peerNickname) (\(peerNoisePublicKey.hexEncodedString()))", SecureLogger.info("⭐️ Removing favorite: \(existing.peerNickname) (\(peerNoisePublicKey.hexEncodedString()))", category: .session)
category: SecureLogger.session, level: .info)
// If they still favorite us, keep the record but mark us as not favoriting // If they still favorite us, keep the record but mark us as not favoriting
if existing.theyFavoritedUs { if existing.theyFavoritedUs {
@@ -125,8 +129,7 @@ class FavoritesPersistenceService: ObservableObject {
let existing = favorites[peerNoisePublicKey] let existing = favorites[peerNoisePublicKey]
let displayName = peerNickname ?? existing?.peerNickname ?? "Unknown" let displayName = peerNickname ?? existing?.peerNickname ?? "Unknown"
SecureLogger.log("📨 Received favorite notification: \(displayName) \(favorited ? "favorited" : "unfavorited") us", SecureLogger.info("📨 Received favorite notification: \(displayName) \(favorited ? "favorited" : "unfavorited") us", category: .session)
category: SecureLogger.session, level: .info)
let relationship = FavoriteRelationship( let relationship = FavoriteRelationship(
peerNoisePublicKey: peerNoisePublicKey, peerNoisePublicKey: peerNoisePublicKey,
@@ -147,8 +150,7 @@ class FavoritesPersistenceService: ObservableObject {
// Check if this creates a mutual favorite // Check if this creates a mutual favorite
if relationship.isMutual { if relationship.isMutual {
SecureLogger.log("💕 Mutual favorite relationship established with \(displayName)!", SecureLogger.info("💕 Mutual favorite relationship established with \(displayName)!", category: .session)
category: SecureLogger.session, level: .info)
} }
} }
@@ -179,136 +181,24 @@ class FavoritesPersistenceService: ObservableObject {
/// Resolve favorite status by short peer ID (16-hex derived from Noise pubkey) /// Resolve favorite status by short peer ID (16-hex derived from Noise pubkey)
/// Falls back to scanning favorites and matching on derived peer ID. /// 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) // Quick sanity: peerID should be 16 hex chars (8 bytes)
guard peerID.count == 16 else { return nil } guard peerID.isShort else { return nil }
for (pubkey, rel) in favorites { for (pubkey, rel) in favorites where PeerID(publicKey: pubkey) == peerID {
let derived = PeerIDUtils.derivePeerID(fromPublicKey: pubkey) return rel
if derived == peerID { return rel }
} }
return nil 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 /// Clear all favorites - used for panic mode
func clearAllFavorites() { func clearAllFavorites() {
SecureLogger.log("🧹 Clearing all favorites (panic mode)", category: SecureLogger.session, level: .warning) SecureLogger.warning("🧹 Clearing all favorites (panic mode)", category: .session)
favorites.removeAll() favorites.removeAll()
saveFavorites() saveFavorites()
// Delete from keychain directly // Delete from keychain directly
KeychainHelper.delete( keychain.delete(
key: Self.storageKey, key: Self.storageKey,
service: Self.keychainService service: Self.keychainService
) )
@@ -328,22 +218,23 @@ class FavoritesPersistenceService: ObservableObject {
let data = try encoder.encode(relationships) let data = try encoder.encode(relationships)
// Store in keychain for security // Store in keychain for security
KeychainHelper.save( keychain.save(
key: Self.storageKey, key: Self.storageKey,
data: data, data: data,
service: Self.keychainService service: Self.keychainService,
accessible: nil
) )
// Successfully saved favorites // Successfully saved favorites
} catch { } catch {
SecureLogger.log("Failed to save favorites: \(error)", category: SecureLogger.session, level: .error) SecureLogger.error("Failed to save favorites: \(error)", category: .session)
} }
} }
private func loadFavorites() { private func loadFavorites() {
// Loading favorites from keychain // Loading favorites from keychain
guard let data = KeychainHelper.load( guard let data = keychain.load(
key: Self.storageKey, key: Self.storageKey,
service: Self.keychainService service: Self.keychainService
) else { ) else {
@@ -354,14 +245,12 @@ class FavoritesPersistenceService: ObservableObject {
let decoder = JSONDecoder() let decoder = JSONDecoder()
let relationships = try decoder.decode([FavoriteRelationship].self, from: data) let relationships = try decoder.decode([FavoriteRelationship].self, from: data)
SecureLogger.log("✅ Loaded \(relationships.count) favorite relationships", SecureLogger.info("✅ Loaded \(relationships.count) favorite relationships", category: .session)
category: SecureLogger.session, level: .info)
// Log Nostr public key info // Log Nostr public key info
for relationship in relationships { for relationship in relationships {
if relationship.peerNostrPublicKey == nil { if relationship.peerNostrPublicKey == nil {
SecureLogger.log("⚠️ No Nostr public key stored for '\(relationship.peerNickname)'", SecureLogger.warning("⚠️ No Nostr public key stored for '\(relationship.peerNickname)'", category: .session)
category: SecureLogger.session, level: .warning)
} }
} }
@@ -372,8 +261,7 @@ class FavoritesPersistenceService: ObservableObject {
for relationship in relationships { for relationship in relationships {
// Check for duplicates by public key (the actual unique identifier) // Check for duplicates by public key (the actual unique identifier)
if let existing = seenPublicKeys[relationship.peerNoisePublicKey] { if let existing = seenPublicKeys[relationship.peerNoisePublicKey] {
SecureLogger.log("⚠️ Duplicate favorite found for public key \(relationship.peerNoisePublicKey.hexEncodedString()) - nicknames: '\(existing.peerNickname)' vs '\(relationship.peerNickname)'", SecureLogger.warning("⚠️ Duplicate favorite found for public key \(relationship.peerNoisePublicKey.hexEncodedString()) - nicknames: '\(existing.peerNickname)' vs '\(relationship.peerNickname)'", category: .session)
category: SecureLogger.session, level: .warning)
// Keep the most recent or most complete relationship // Keep the most recent or most complete relationship
if relationship.lastUpdated > existing.lastUpdated || if relationship.lastUpdated > existing.lastUpdated ||
@@ -414,7 +302,7 @@ class FavoritesPersistenceService: ObservableObject {
// Log loaded relationships // Log loaded relationships
// Loaded relationships successfully // Loaded relationships successfully
} catch { } catch {
SecureLogger.log("Failed to load favorites: \(error)", category: SecureLogger.session, level: .error) SecureLogger.error("Failed to load favorites: \(error)", category: .session)
} }
} }
} }
@@ -0,0 +1,219 @@
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 let storage: UserDefaults
init(storage: UserDefaults = .standard) {
self.storage = storage
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 = storage.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 = storage.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) {
storage.set(data, forKey: storeKey)
}
}
private func persistNames() {
if let data = try? JSONEncoder().encode(bookmarkNames) {
storage.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
}
+26 -46
View File
@@ -6,54 +6,33 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import BitLogger
import Foundation import Foundation
import Security import Security
import os.log
class KeychainManager { protocol KeychainManagerProtocol {
static let shared = KeychainManager() func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool
func getIdentityKey(forKey key: String) -> Data?
func deleteIdentityKey(forKey key: String) -> Bool
func deleteAllKeychainData() -> Bool
func secureClear(_ data: inout Data)
func secureClear(_ string: inout String)
func verifyIdentityKeyExists() -> Bool
}
final class KeychainManager: KeychainManagerProtocol {
// Use consistent service name for all keychain items // Use consistent service name for all keychain items
private let service = "chat.bitchat" private let service = BitchatApp.bundleID
private let appGroup = "group.chat.bitchat" private let appGroup = "group.\(BitchatApp.bundleID)"
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
}
// MARK: - Identity Keys // MARK: - Identity Keys
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool { func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
let fullKey = "identity_\(key)" let fullKey = "identity_\(key)"
let result = saveData(keyData, forKey: fullKey) let result = saveData(keyData, forKey: fullKey)
SecureLogger.logKeyOperation("save", keyType: key, success: result) SecureLogger.logKeyOperation(.save, keyType: key, success: result)
return result return result
} }
@@ -64,7 +43,7 @@ class KeychainManager {
func deleteIdentityKey(forKey key: String) -> Bool { func deleteIdentityKey(forKey key: String) -> Bool {
let result = delete(forKey: "identity_\(key)") let result = delete(forKey: "identity_\(key)")
SecureLogger.logKeyOperation("delete", keyType: key, success: result) SecureLogger.logKeyOperation(.delete, keyType: key, success: result)
return result return result
} }
@@ -113,9 +92,9 @@ class KeychainManager {
if status == errSecSuccess { return true } if status == errSecSuccess { return true }
if status == -34018 && !triedWithoutGroup { if status == -34018 && !triedWithoutGroup {
SecureLogger.logError(NSError(domain: "Keychain", code: -34018), context: "Missing keychain entitlement", category: SecureLogger.keychain) SecureLogger.error(NSError(domain: "Keychain", code: -34018), context: "Missing keychain entitlement", category: .keychain)
} else if status != errSecDuplicateItem { } else if status != errSecDuplicateItem {
SecureLogger.logError(NSError(domain: "Keychain", code: Int(status)), context: "Error saving to keychain", category: SecureLogger.keychain) SecureLogger.error(NSError(domain: "Keychain", code: Int(status)), context: "Error saving to keychain", category: .keychain)
} }
return false return false
} }
@@ -151,7 +130,7 @@ class KeychainManager {
if status == errSecSuccess { return result as? Data } if status == errSecSuccess { return result as? Data }
if status == -34018 { if status == -34018 {
SecureLogger.logError(NSError(domain: "Keychain", code: -34018), context: "Missing keychain entitlement", category: SecureLogger.keychain) SecureLogger.error(NSError(domain: "Keychain", code: -34018), context: "Missing keychain entitlement", category: .keychain)
} }
return nil return nil
} }
@@ -198,7 +177,7 @@ class KeychainManager {
// Delete ALL keychain data for panic mode // Delete ALL keychain data for panic mode
func deleteAllKeychainData() -> Bool { func deleteAllKeychainData() -> Bool {
SecureLogger.log("Panic mode - deleting all keychain data", category: SecureLogger.security, level: .warning) SecureLogger.warning("Panic mode - deleting all keychain data", category: .security)
var totalDeleted = 0 var totalDeleted = 0
@@ -261,7 +240,7 @@ class KeychainManager {
let deleteStatus = SecItemDelete(deleteQuery as CFDictionary) let deleteStatus = SecItemDelete(deleteQuery as CFDictionary)
if deleteStatus == errSecSuccess { if deleteStatus == errSecSuccess {
totalDeleted += 1 totalDeleted += 1
SecureLogger.log("Deleted keychain item: \(account) from \(service)", category: SecureLogger.keychain, level: .info) SecureLogger.info("Deleted keychain item: \(account) from \(service)", category: .keychain)
} }
} }
} }
@@ -275,6 +254,7 @@ class KeychainManager {
"com.bitchat.deviceidentity", "com.bitchat.deviceidentity",
"com.bitchat.noise.identity", "com.bitchat.noise.identity",
"chat.bitchat.passwords", "chat.bitchat.passwords",
"chat.bitchat.nostr",
"bitchat.keychain", "bitchat.keychain",
"bitchat", "bitchat",
"com.bitchat" "com.bitchat"
@@ -303,7 +283,7 @@ class KeychainManager {
totalDeleted += 1 totalDeleted += 1
} }
SecureLogger.log("Panic mode cleanup completed. Total items deleted: \(totalDeleted)", category: SecureLogger.keychain, level: .warning) SecureLogger.warning("Panic mode cleanup completed. Total items deleted: \(totalDeleted)", category: .keychain)
return totalDeleted > 0 return totalDeleted > 0
} }
@@ -311,7 +291,7 @@ class KeychainManager {
// MARK: - Security Utilities // MARK: - Security Utilities
/// Securely clear sensitive data from memory /// Securely clear sensitive data from memory
static func secureClear(_ data: inout Data) { func secureClear(_ data: inout Data) {
_ = data.withUnsafeMutableBytes { bytes in _ = data.withUnsafeMutableBytes { bytes in
// Use volatile memset to prevent compiler optimization // Use volatile memset to prevent compiler optimization
memset_s(bytes.baseAddress, bytes.count, 0, bytes.count) memset_s(bytes.baseAddress, bytes.count, 0, bytes.count)
@@ -320,7 +300,7 @@ class KeychainManager {
} }
/// Securely clear sensitive string from memory /// Securely clear sensitive string from memory
static func secureClear(_ string: inout String) { func secureClear(_ string: inout String) {
// Convert to mutable data and clear // Convert to mutable data and clear
if var data = string.data(using: .utf8) { if var data = string.data(using: .utf8) {
secureClear(&data) secureClear(&data)
+75 -31
View File
@@ -1,9 +1,10 @@
import BitLogger
import Foundation import Foundation
#if os(iOS)
import CoreLocation
import Combine import Combine
#if os(iOS) || os(macOS)
import CoreLocation
/// Manages location permissions, one-shot location retrieval, and computing geohash channels. /// 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. /// Not main-actor isolated to satisfy CLLocationManagerDelegate in Swift 6; state updates hop to MainActor.
final class LocationChannelManager: NSObject, CLLocationManagerDelegate, ObservableObject { final class LocationChannelManager: NSObject, CLLocationManagerDelegate, ObservableObject {
@@ -39,7 +40,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
super.init() super.init()
cl.delegate = self cl.delegate = self
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
cl.distanceFilter = 1000 // meters; we're not tracking continuously cl.distanceFilter = TransportConfig.locationDistanceFilterMeters // meters; we're not tracking continuously
// Load selection // Load selection
if let data = UserDefaults.standard.data(forKey: userDefaultsKey), if let data = UserDefaults.standard.data(forKey: userDefaultsKey),
let channel = try? JSONDecoder().decode(ChannelID.self, from: data) { let channel = try? JSONDecoder().decode(ChannelID.self, from: data) {
@@ -50,23 +51,30 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
let arr = try? JSONDecoder().decode([String].self, from: data) { let arr = try? JSONDecoder().decode([String].self, from: data) {
teleportedSet = Set(arr) teleportedSet = Set(arr)
} }
// Initialize teleported flag from persisted state if a location channel is selected // Do not eagerly mark teleported on startup; wait for location to compute regional set.
if case .location(let ch) = selectedChannel { // This avoids showing teleported for in-region channels during cold start.
teleported = teleportedSet.contains(ch.geohash)
}
let status: CLAuthorizationStatus let status: CLAuthorizationStatus
if #available(iOS 14.0, *) { if #available(iOS 14.0, macOS 11.0, *) {
status = cl.authorizationStatus status = cl.authorizationStatus
} else { } else {
status = CLLocationManager.authorizationStatus() status = CLLocationManager.authorizationStatus()
} }
updatePermissionState(from: status) 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 // MARK: - Public API
func enableLocationChannels() { func enableLocationChannels() {
let status: CLAuthorizationStatus let status: CLAuthorizationStatus
if #available(iOS 14.0, *) { if #available(iOS 14.0, macOS 11.0, *) {
status = cl.authorizationStatus status = cl.authorizationStatus
} else { } else {
status = CLLocationManager.authorizationStatus() status = CLLocationManager.authorizationStatus()
@@ -78,7 +86,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
Task { @MainActor in self.permissionState = .restricted } Task { @MainActor in self.permissionState = .restricted }
case .denied: case .denied:
Task { @MainActor in self.permissionState = .denied } Task { @MainActor in self.permissionState = .denied }
case .authorizedAlways, .authorizedWhenInUse: case .authorizedAlways, .authorizedWhenInUse, .authorized:
Task { @MainActor in self.permissionState = .authorized } Task { @MainActor in self.permissionState = .authorized }
requestOneShotLocation() requestOneShotLocation()
@unknown default: @unknown default:
@@ -92,23 +100,30 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
} }
} }
/// Begin periodic one-shot location refreshes while a selector UI is visible. /// Begin continuous, distance-filtered updates while the channel sheet is visible.
func beginLiveRefresh(interval: TimeInterval = 5.0) { /// Uses a 21m filter (configurable) to only refresh on meaningful movement.
func beginLiveRefresh(interval: TimeInterval = TransportConfig.locationLiveRefreshInterval) {
guard permissionState == .authorized else { return } guard permissionState == .authorized else { return }
// Switch to a lightweight periodic one-shot request (polling) while the sheet is open // Stop any previous polling timer
refreshTimer?.invalidate() refreshTimer?.invalidate()
refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in refreshTimer = nil
self?.requestOneShotLocation() // Tighten accuracy and distance filter for live view
} cl.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
// Kick off immediately cl.distanceFilter = TransportConfig.locationDistanceFilterLiveMeters
// Start continuous updates
cl.startUpdatingLocation()
// Request an immediate fix to populate UI without waiting for movement
requestOneShotLocation() requestOneShotLocation()
} }
/// Stop periodic refreshes when selector UI is dismissed. /// Stop continuous refreshes when selector UI is dismissed.
func endLiveRefresh() { func endLiveRefresh() {
refreshTimer?.invalidate() refreshTimer?.invalidate()
refreshTimer = nil refreshTimer = nil
cl.stopUpdatingLocation() cl.stopUpdatingLocation()
// Restore more relaxed defaults for background/idle state
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters
} }
func select(_ channel: ChannelID) { func select(_ channel: ChannelID) {
@@ -122,7 +137,21 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
case .mesh: case .mesh:
self.teleported = false self.teleported = false
case .location(let ch): case .location(let ch):
self.teleported = self.teleportedSet.contains(ch.geohash) // 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)
}
} }
} }
} }
@@ -151,8 +180,8 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
} }
} }
// iOS 14+ // iOS 14+ / macOS 11+
@available(iOS 14.0, *) @available(iOS 14.0, macOS 11.0, *)
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
updatePermissionState(from: manager.authorizationStatus) updatePermissionState(from: manager.authorizationStatus)
if case .authorized = permissionState { if case .authorized = permissionState {
@@ -169,8 +198,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
// Surface as denied/restricted if relevant; otherwise keep previous state // Surface as denied/restricted if relevant; otherwise keep previous state
SecureLogger.log("LocationChannelManager: location error: \(error.localizedDescription)", SecureLogger.error("LocationChannelManager: location error: \(error.localizedDescription)", category: .session)
category: SecureLogger.session, level: .error)
} }
// MARK: - Helpers // MARK: - Helpers
@@ -180,7 +208,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
case .notDetermined: newState = .notDetermined case .notDetermined: newState = .notDetermined
case .restricted: newState = .restricted case .restricted: newState = .restricted
case .denied: newState = .denied case .denied: newState = .denied
case .authorizedAlways, .authorizedWhenInUse: newState = .authorized case .authorizedAlways, .authorizedWhenInUse, .authorized: newState = .authorized
@unknown default: newState = .restricted @unknown default: newState = .restricted
} }
Task { @MainActor in self.permissionState = newState } Task { @MainActor in self.permissionState = newState }
@@ -195,14 +223,25 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
} }
Task { @MainActor in Task { @MainActor in
self.availableChannels = result self.availableChannels = result
// Recompute teleported status based on persisted state OR current location vs selected channel // Recompute teleported status based on whether the selected geohash is in our regional set
switch self.selectedChannel { switch self.selectedChannel {
case .mesh: case .mesh:
self.teleported = false self.teleported = false
case .location(let ch): case .location(let ch):
let persisted = self.teleportedSet.contains(ch.geohash) // Membership check using freshly computed regional channels; avoids precision/rename drift
let currentGH = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: ch.level.precision) let inRegional = result.contains { $0.geohash == ch.geohash }
self.teleported = persisted || (currentGH != 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
}
} }
} }
} }
@@ -247,14 +286,19 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
} else if let locality = pm.locality, !locality.isEmpty { } else if let locality = pm.locality, !locality.isEmpty {
dict[.neighborhood] = locality dict[.neighborhood] = locality
} }
// Block: reuse neighborhood/locality granularity without exposing street level // Block: reuse neighborhood/locality granularity
if let subLocality = pm.subLocality, !subLocality.isEmpty { if let subLocality = pm.subLocality, !subLocality.isEmpty {
dict[.block] = subLocality dict[.block] = subLocality
} else if let locality = pm.locality, !locality.isEmpty { } else if let locality = pm.locality, !locality.isEmpty {
dict[.block] = locality dict[.block] = locality
} }
// Building: prefer place name/street/venue when available
if let name = pm.name, !name.isEmpty {
dict[.building] = name
} else if let thoroughfare = pm.thoroughfare, !thoroughfare.isEmpty {
dict[.building] = thoroughfare
}
return dict return dict
} }
} }
#endif #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
}
}
+50 -40
View File
@@ -1,3 +1,4 @@
import BitLogger
import Foundation import Foundation
/// Routes messages between BLE and Nostr transports /// Routes messages between BLE and Nostr transports
@@ -5,7 +6,7 @@ import Foundation
final class MessageRouter { final class MessageRouter {
private let mesh: Transport private let mesh: Transport
private let nostr: NostrTransport private let nostr: NostrTransport
private var outbox: [String: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages private var outbox: [PeerID: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
init(mesh: Transport, nostr: NostrTransport) { init(mesh: Transport, nostr: NostrTransport) {
self.mesh = mesh self.mesh = mesh
@@ -20,7 +21,7 @@ final class MessageRouter {
) { [weak self] note in ) { [weak self] note in
guard let self = self else { return } guard let self = self else { return }
if let data = note.userInfo?["peerPublicKey"] as? Data { if let data = note.userInfo?["peerPublicKey"] as? Data {
let peerID = PeerIDUtils.derivePeerID(fromPublicKey: data) let peerID = PeerID(publicKey: data)
Task { @MainActor in Task { @MainActor in
self.flushOutbox(for: peerID) self.flushOutbox(for: peerID)
} }
@@ -28,7 +29,7 @@ final class MessageRouter {
// Handle key updates // Handle key updates
if let newKey = note.userInfo?["peerPublicKey"] as? Data, if let newKey = note.userInfo?["peerPublicKey"] as? Data,
let _ = note.userInfo?["isKeyUpdate"] as? Bool { let _ = note.userInfo?["isKeyUpdate"] as? Bool {
let peerID = PeerIDUtils.derivePeerID(fromPublicKey: newKey) let peerID = PeerID(publicKey: newKey)
Task { @MainActor in Task { @MainActor in
self.flushOutbox(for: peerID) self.flushOutbox(for: peerID)
} }
@@ -36,48 +37,45 @@ final class MessageRouter {
} }
} }
func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) { func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
let hasMesh = mesh.isPeerConnected(peerID) let reachableMesh = mesh.isPeerReachable(peerID)
let hasEstablished = mesh.getNoiseService().hasEstablishedSession(with: peerID) if reachableMesh {
if hasMesh && hasEstablished { SecureLogger.debug("Routing PM via mesh (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
SecureLogger.log("Routing PM via mesh to \(peerID.prefix(8))… id=\(messageID.prefix(8))", // BLEService will initiate a handshake if needed and queue the message
category: SecureLogger.session, level: .debug)
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
} else if canSendViaNostr(peerID: peerID) { } else if canSendViaNostr(peerID: peerID) {
SecureLogger.log("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))", SecureLogger.debug("Routing PM via Nostr to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
category: SecureLogger.session, level: .debug)
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
} else { } else {
// Queue for later (when mesh connects or Nostr mapping appears) // Queue for later (when mesh connects or Nostr mapping appears)
if outbox[peerID] == nil { outbox[peerID] = [] } if outbox[peerID] == nil { outbox[peerID] = [] }
outbox[peerID]?.append((content, recipientNickname, messageID)) outbox[peerID]?.append((content, recipientNickname, messageID))
SecureLogger.log("Queued PM for \(peerID.prefix(8))… (no mesh, no Nostr mapping) id=\(messageID.prefix(8))", SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no mesh, no Nostr mapping) id=\(messageID.prefix(8))", category: .session)
category: SecureLogger.session, level: .debug)
} }
} }
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) { func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
// Prefer mesh only if a Noise session is established; else use Nostr to avoid handshakeRequired spam // Prefer mesh for reachable peers; BLE will queue if handshake is needed
if mesh.isPeerConnected(peerID) && mesh.getNoiseService().hasEstablishedSession(with: peerID) { if mesh.isPeerReachable(peerID) {
SecureLogger.log("Routing READ ack via mesh to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))", SecureLogger.debug("Routing READ ack via mesh (reachable) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))", category: .session)
category: SecureLogger.session, level: .debug)
mesh.sendReadReceipt(receipt, to: peerID) mesh.sendReadReceipt(receipt, to: peerID)
} else { } else {
SecureLogger.log("Routing READ ack via Nostr to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))", SecureLogger.debug("Routing READ ack via Nostr to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))", category: .session)
category: SecureLogger.session, level: .debug)
nostr.sendReadReceipt(receipt, to: peerID) nostr.sendReadReceipt(receipt, to: peerID)
} }
} }
func sendDeliveryAck(_ messageID: String, to peerID: String) { func sendDeliveryAck(_ messageID: String, to peerID: PeerID) {
if mesh.isPeerConnected(peerID) && mesh.getNoiseService().hasEstablishedSession(with: peerID) { if mesh.isPeerReachable(peerID) {
SecureLogger.debug("Routing DELIVERED ack via mesh (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
mesh.sendDeliveryAck(for: messageID, to: peerID) mesh.sendDeliveryAck(for: messageID, to: peerID)
} else { } else {
nostr.sendDeliveryAck(for: messageID, to: peerID) nostr.sendDeliveryAck(for: messageID, to: peerID)
} }
} }
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) { func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
// Route via mesh when connected; else use Nostr
if mesh.isPeerConnected(peerID) { if mesh.isPeerConnected(peerID) {
mesh.sendFavoriteNotification(to: peerID, isFavorite: isFavorite) mesh.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
} else { } else {
@@ -86,38 +84,50 @@ final class MessageRouter {
} }
// MARK: - Outbox Management // MARK: - Outbox Management
private func canSendViaNostr(peerID: String) -> Bool { private func canSendViaNostr(peerID: PeerID) -> Bool {
guard let noiseKey = Data(hexString: peerID) else { return false } // Two forms are supported:
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), // - 64-hex Noise public key (32 bytes)
fav.peerNostrPublicKey != nil { // - 16-hex short peer ID (derived from Noise pubkey)
return true if let noiseKey = peerID.noiseKey {
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
fav.peerNostrPublicKey != nil {
return true
}
} else if peerID.isShort {
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
fav.peerNostrPublicKey != nil {
return true
}
} }
return false return false
} }
func flushOutbox(for peerID: String) { func flushOutbox(for peerID: PeerID) {
guard let queued = outbox[peerID], !queued.isEmpty else { return } guard let queued = outbox[peerID], !queued.isEmpty else { return }
SecureLogger.log("Flushing outbox for \(peerID.prefix(8))… count=\(queued.count)", SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
category: SecureLogger.session, level: .debug) var remaining: [(content: String, nickname: String, messageID: String)] = []
// Prefer mesh if connected; else try Nostr if mapping exists // Prefer mesh if connected; else try Nostr if mapping exists
for (content, nickname, messageID) in queued { for (content, nickname, messageID) in queued {
if mesh.isPeerConnected(peerID) { if mesh.isPeerReachable(peerID) {
SecureLogger.log("Outbox -> mesh for \(peerID.prefix(8))… id=\(messageID.prefix(8))", SecureLogger.debug("Outbox -> mesh for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
category: SecureLogger.session, level: .debug)
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID) mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
} else if canSendViaNostr(peerID: peerID) { } else if canSendViaNostr(peerID: peerID) {
SecureLogger.log("Outbox -> Nostr for \(peerID.prefix(8))… id=\(messageID.prefix(8))", SecureLogger.debug("Outbox -> Nostr for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
category: SecureLogger.session, level: .debug)
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID) nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
} else { } else {
continue // Keep unsent items queued
remaining.append((content, nickname, messageID))
} }
} }
// Remove all flushed items (remaining ones, if any, will be re-queued on next call) // Persist only items we could not send
outbox[peerID]?.removeAll() if remaining.isEmpty {
outbox.removeValue(forKey: peerID)
} else {
outbox[peerID] = remaining
}
} }
func flushAllOutbox() { 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()
}
}
}
+73 -74
View File
@@ -62,7 +62,6 @@
/// ## Integration Points /// ## Integration Points
/// - **BLEService**: Calls this service for all private messages /// - **BLEService**: Calls this service for all private messages
/// - **ChatViewModel**: Monitors encryption status for UI indicators /// - **ChatViewModel**: Monitors encryption status for UI indicators
/// - **NoiseHandshakeCoordinator**: Prevents handshake race conditions
/// - **KeychainManager**: Secure storage for identity keys /// - **KeychainManager**: Secure storage for identity keys
/// ///
/// ## Thread Safety /// ## Thread Safety
@@ -83,9 +82,9 @@
/// - Background queue for CPU-intensive operations /// - Background queue for CPU-intensive operations
/// ///
import BitLogger
import Foundation import Foundation
import CryptoKit import CryptoKit
import os.log
// MARK: - Encryption Status // MARK: - Encryption Status
@@ -116,15 +115,30 @@ enum EncryptionStatus: Equatable {
var description: String { var description: String {
switch self { switch self {
case .none: case .none:
return "Encryption failed" return String(localized: "encryption.status.failed", comment: "Status text when encryption failed")
case .noHandshake: case .noHandshake:
return "Not encrypted" return String(localized: "encryption.status.not_encrypted", comment: "Status text when no encryption handshake happened")
case .noiseHandshaking: case .noiseHandshaking:
return "Establishing encryption..." return String(localized: "encryption.status.establishing", comment: "Status text when encryption is being established")
case .noiseSecured: case .noiseSecured:
return "Encrypted" return String(localized: "encryption.status.secured", comment: "Status text when encryption is secured but not verified")
case .noiseVerified: 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, /// Provides a high-level API for establishing secure channels between peers,
/// handling all cryptographic operations transparently. /// handling all cryptographic operations transparently.
/// - Important: This service maintains the device's cryptographic identity /// - Important: This service maintains the device's cryptographic identity
class NoiseEncryptionService { final class NoiseEncryptionService {
// Static identity key (persistent across sessions) // Static identity key (persistent across sessions)
private let staticIdentityKey: Curve25519.KeyAgreement.PrivateKey private let staticIdentityKey: Curve25519.KeyAgreement.PrivateKey
public let staticIdentityPublicKey: Curve25519.KeyAgreement.PublicKey public let staticIdentityPublicKey: Curve25519.KeyAgreement.PublicKey
@@ -148,14 +162,15 @@ class NoiseEncryptionService {
private let sessionManager: NoiseSessionManager private let sessionManager: NoiseSessionManager
// Peer fingerprints (SHA256 hash of static public key) // Peer fingerprints (SHA256 hash of static public key)
private var peerFingerprints: [String: String] = [:] // peerID -> fingerprint private var peerFingerprints: [PeerID: String] = [:]
private var fingerprintToPeerID: [String: String] = [:] // fingerprint -> peerID private var fingerprintToPeerID: [String: PeerID] = [:]
// Thread safety // Thread safety
private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent) private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent)
// Security components // Security components
private let rateLimiter = NoiseRateLimiter() private let rateLimiter = NoiseRateLimiter()
private let keychain: KeychainManagerProtocol
// Session maintenance // Session maintenance
private var rekeyTimer: Timer? private var rekeyTimer: Timer?
@@ -163,7 +178,7 @@ class NoiseEncryptionService {
// Callbacks // Callbacks
private var onPeerAuthenticatedHandlers: [((String, String) -> Void)] = [] // Array of handlers for peer authentication private var onPeerAuthenticatedHandlers: [((String, String) -> Void)] = [] // Array of handlers for peer authentication
var onHandshakeRequired: ((String) -> Void)? // peerID needs handshake var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
// Add a handler for peer authentication // Add a handler for peer authentication
func addOnPeerAuthenticatedHandler(_ handler: @escaping (String, String) -> Void) { func addOnPeerAuthenticatedHandler(_ handler: @escaping (String, String) -> Void) {
@@ -182,15 +197,17 @@ class NoiseEncryptionService {
} }
} }
init() { init(keychain: KeychainManagerProtocol) {
self.keychain = keychain
// Load or create static identity key (ONLY from keychain) // Load or create static identity key (ONLY from keychain)
let loadedKey: Curve25519.KeyAgreement.PrivateKey let loadedKey: Curve25519.KeyAgreement.PrivateKey
// Try to load from keychain // Try to load from keychain
if let identityData = KeychainManager.shared.getIdentityKey(forKey: "noiseStaticKey"), if let identityData = keychain.getIdentityKey(forKey: "noiseStaticKey"),
let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: identityData) { let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: identityData) {
loadedKey = key loadedKey = key
SecureLogger.logKeyOperation("load", keyType: "noiseStaticKey", success: true) SecureLogger.logKeyOperation(.load, keyType: "noiseStaticKey", success: true)
} }
// If no identity exists, create new one // If no identity exists, create new one
else { else {
@@ -198,8 +215,8 @@ class NoiseEncryptionService {
let keyData = loadedKey.rawRepresentation let keyData = loadedKey.rawRepresentation
// Save to keychain // Save to keychain
let saved = KeychainManager.shared.saveIdentityKey(keyData, forKey: "noiseStaticKey") let saved = keychain.saveIdentityKey(keyData, forKey: "noiseStaticKey")
SecureLogger.logKeyOperation("create", keyType: "noiseStaticKey", success: saved) SecureLogger.logKeyOperation(.create, keyType: "noiseStaticKey", success: saved)
} }
// Now assign the final value // Now assign the final value
@@ -210,10 +227,10 @@ class NoiseEncryptionService {
let loadedSigningKey: Curve25519.Signing.PrivateKey let loadedSigningKey: Curve25519.Signing.PrivateKey
// Try to load from keychain // Try to load from keychain
if let signingData = KeychainManager.shared.getIdentityKey(forKey: "ed25519SigningKey"), if let signingData = keychain.getIdentityKey(forKey: "ed25519SigningKey"),
let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingData) { let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingData) {
loadedSigningKey = key loadedSigningKey = key
SecureLogger.logKeyOperation("load", keyType: "ed25519SigningKey", success: true) SecureLogger.logKeyOperation(.load, keyType: "ed25519SigningKey", success: true)
} }
// If no signing key exists, create new one // If no signing key exists, create new one
else { else {
@@ -221,8 +238,8 @@ class NoiseEncryptionService {
let keyData = loadedSigningKey.rawRepresentation let keyData = loadedSigningKey.rawRepresentation
// Save to keychain // Save to keychain
let saved = KeychainManager.shared.saveIdentityKey(keyData, forKey: "ed25519SigningKey") let saved = keychain.saveIdentityKey(keyData, forKey: "ed25519SigningKey")
SecureLogger.logKeyOperation("create", keyType: "ed25519SigningKey", success: saved) SecureLogger.logKeyOperation(.create, keyType: "ed25519SigningKey", success: saved)
} }
// Now assign the signing keys // Now assign the signing keys
@@ -230,7 +247,7 @@ class NoiseEncryptionService {
self.signingPublicKey = signingKey.publicKey self.signingPublicKey = signingKey.publicKey
// Initialize session manager // Initialize session manager
self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey) self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain)
// Set up session callbacks // Set up session callbacks
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in
@@ -255,22 +272,21 @@ class NoiseEncryptionService {
/// Get our identity fingerprint /// Get our identity fingerprint
func getIdentityFingerprint() -> String { func getIdentityFingerprint() -> String {
let hash = SHA256.hash(data: staticIdentityPublicKey.rawRepresentation) staticIdentityPublicKey.rawRepresentation.sha256Fingerprint()
return hash.map { String(format: "%02x", $0) }.joined()
} }
/// Get peer's public key data /// Get peer's public key data
func getPeerPublicKeyData(_ peerID: String) -> Data? { func getPeerPublicKeyData(_ peerID: PeerID) -> Data? {
return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation
} }
/// Clear persistent identity (for panic mode) /// Clear persistent identity (for panic mode)
func clearPersistentIdentity() { func clearPersistentIdentity() {
// Clear from keychain // Clear from keychain
let deletedStatic = KeychainManager.shared.deleteIdentityKey(forKey: "noiseStaticKey") let deletedStatic = keychain.deleteIdentityKey(forKey: "noiseStaticKey")
let deletedSigning = KeychainManager.shared.deleteIdentityKey(forKey: "ed25519SigningKey") let deletedSigning = keychain.deleteIdentityKey(forKey: "ed25519SigningKey")
SecureLogger.logKeyOperation("delete", keyType: "identity keys", success: deletedStatic && deletedSigning) SecureLogger.logKeyOperation(.delete, keyType: "identity keys", success: deletedStatic && deletedSigning)
SecureLogger.log("Panic mode activated - identity cleared", category: SecureLogger.security, level: .warning) SecureLogger.warning("Panic mode activated - identity cleared", category: .security)
// Stop rekey timer // Stop rekey timer
stopRekeyTimer() stopRekeyTimer()
} }
@@ -281,7 +297,7 @@ class NoiseEncryptionService {
let signature = try signingKey.signature(for: data) let signature = try signingKey.signature(for: data)
return signature return signature
} catch { } catch {
SecureLogger.logError(error, context: "Failed to sign data", category: SecureLogger.noise) SecureLogger.error(error, context: "Failed to sign data")
return nil return nil
} }
} }
@@ -292,7 +308,7 @@ class NoiseEncryptionService {
let signingPublicKey = try Curve25519.Signing.PublicKey(rawRepresentation: publicKey) let signingPublicKey = try Curve25519.Signing.PublicKey(rawRepresentation: publicKey)
return signingPublicKey.isValidSignature(signature, for: data) return signingPublicKey.isValidSignature(signature, for: data)
} catch { } catch {
SecureLogger.logError(error, context: "Failed to verify signature", category: SecureLogger.noise) SecureLogger.error(error, context: "Failed to verify signature")
return false return false
} }
} }
@@ -388,21 +404,21 @@ class NoiseEncryptionService {
// MARK: - Handshake Management // MARK: - Handshake Management
/// Initiate a Noise handshake with a peer /// Initiate a Noise handshake with a peer
func initiateHandshake(with peerID: String) throws -> Data { func initiateHandshake(with peerID: PeerID) throws -> Data {
// Validate peer ID // Validate peer ID
guard NoiseSecurityValidator.validatePeerID(peerID) else { guard peerID.isValid else {
SecureLogger.logSecurityEvent(.authenticationFailed(peerID: peerID), level: .warning) SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
throw NoiseSecurityError.invalidPeerID throw NoiseSecurityError.invalidPeerID
} }
// Check rate limit // Check rate limit
guard rateLimiter.allowHandshake(from: peerID) else { guard rateLimiter.allowHandshake(from: peerID) else {
SecureLogger.logSecurityEvent(.authenticationFailed(peerID: "Rate limited: \(peerID)"), level: .warning) SecureLogger.warning(.authenticationFailed(peerID: "Rate limited: \(peerID)"))
throw NoiseSecurityError.rateLimitExceeded throw NoiseSecurityError.rateLimitExceeded
} }
SecureLogger.logSecurityEvent(.handshakeStarted(peerID: peerID)) SecureLogger.info(.handshakeStarted(peerID: peerID.id))
// Return raw handshake data without wrapper // Return raw handshake data without wrapper
// The Noise protocol handles its own message format // The Noise protocol handles its own message format
@@ -411,23 +427,23 @@ class NoiseEncryptionService {
} }
/// Process an incoming handshake message /// 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 // Validate peer ID
guard NoiseSecurityValidator.validatePeerID(peerID) else { guard peerID.isValid else {
SecureLogger.logSecurityEvent(.authenticationFailed(peerID: peerID), level: .warning) SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
throw NoiseSecurityError.invalidPeerID throw NoiseSecurityError.invalidPeerID
} }
// Validate message size // Validate message size
guard NoiseSecurityValidator.validateHandshakeMessageSize(message) else { guard NoiseSecurityValidator.validateHandshakeMessageSize(message) else {
SecureLogger.logSecurityEvent(.handshakeFailed(peerID: peerID, error: "Message too large"), level: .warning) SecureLogger.warning(.handshakeFailed(peerID: peerID.id, error: "Message too large"))
throw NoiseSecurityError.messageTooLarge throw NoiseSecurityError.messageTooLarge
} }
// Check rate limit // Check rate limit
guard rateLimiter.allowHandshake(from: peerID) else { guard rateLimiter.allowHandshake(from: peerID) else {
SecureLogger.logSecurityEvent(.authenticationFailed(peerID: "Rate limited: \(peerID)"), level: .warning) SecureLogger.warning(.authenticationFailed(peerID: "Rate limited: \(peerID)"))
throw NoiseSecurityError.rateLimitExceeded throw NoiseSecurityError.rateLimitExceeded
} }
@@ -441,19 +457,19 @@ class NoiseEncryptionService {
} }
/// Check if we have an established session with a peer /// 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 return sessionManager.getSession(for: peerID)?.isEstablished() ?? false
} }
/// Check if we have a session (established or handshaking) with a peer /// 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 return sessionManager.getSession(for: peerID) != nil
} }
// MARK: - Encryption/Decryption // MARK: - Encryption/Decryption
/// Encrypt data for a specific peer /// 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 // Validate message size
guard NoiseSecurityValidator.validateMessageSize(data) else { guard NoiseSecurityValidator.validateMessageSize(data) else {
throw NoiseSecurityError.messageTooLarge throw NoiseSecurityError.messageTooLarge
@@ -475,7 +491,7 @@ class NoiseEncryptionService {
} }
/// Decrypt data from a specific peer /// 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 // Validate message size
guard NoiseSecurityValidator.validateMessageSize(data) else { guard NoiseSecurityValidator.validateMessageSize(data) else {
throw NoiseSecurityError.messageTooLarge throw NoiseSecurityError.messageTooLarge
@@ -497,38 +513,26 @@ class NoiseEncryptionService {
// MARK: - Peer Management // MARK: - Peer Management
/// Get fingerprint for a peer /// Get fingerprint for a peer
func getPeerFingerprint(_ peerID: String) -> String? { func getPeerFingerprint(_ peerID: PeerID) -> String? {
return serviceQueue.sync { return serviceQueue.sync {
return peerFingerprints[peerID] return peerFingerprints[peerID]
} }
} }
/// Get peer ID for a fingerprint func clearEphemeralStateForPanic() {
func getPeerID(for fingerprint: String) -> String? { sessionManager.removeAllSessions()
return serviceQueue.sync {
return fingerprintToPeerID[fingerprint]
}
}
/// Remove a peer session
func removePeer(_ peerID: String) {
sessionManager.removeSession(for: peerID)
serviceQueue.sync(flags: .barrier) { serviceQueue.sync(flags: .barrier) {
if let fingerprint = peerFingerprints[peerID] { peerFingerprints.removeAll()
fingerprintToPeerID.removeValue(forKey: fingerprint) fingerprintToPeerID.removeAll()
}
peerFingerprints.removeValue(forKey: peerID)
} }
rateLimiter.resetAll()
SecureLogger.logSecurityEvent(.sessionExpired(peerID: peerID))
} }
// MARK: - Private Helpers // MARK: - Private Helpers
private func handleSessionEstablished(peerID: String, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) { private func handleSessionEstablished(peerID: PeerID, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
// Calculate fingerprint // Calculate fingerprint
let fingerprint = calculateFingerprint(for: remoteStaticKey) let fingerprint = remoteStaticKey.rawRepresentation.sha256Fingerprint()
// Store fingerprint mapping // Store fingerprint mapping
serviceQueue.sync(flags: .barrier) { serviceQueue.sync(flags: .barrier) {
@@ -537,20 +541,15 @@ class NoiseEncryptionService {
} }
// Log security event // Log security event
SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID)) SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
// Notify all handlers about authentication // Notify all handlers about authentication
serviceQueue.async { [weak self] in serviceQueue.async { [weak self] in
self?.onPeerAuthenticatedHandlers.forEach { handler in self?.onPeerAuthenticatedHandlers.forEach { handler in
handler(peerID, fingerprint) handler(peerID.id, fingerprint)
} }
} }
} }
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 // MARK: - Session Maintenance
@@ -573,12 +572,12 @@ class NoiseEncryptionService {
// Attempt to rekey the session // Attempt to rekey the session
do { do {
try sessionManager.initiateRekey(for: peerID) try sessionManager.initiateRekey(for: peerID)
SecureLogger.log("Key rotation initiated for peer: \(peerID)", category: SecureLogger.security, level: .debug) SecureLogger.debug("Key rotation initiated for peer: \(peerID)", category: .security)
// Signal that handshake is needed // Signal that handshake is needed
onHandshakeRequired?(peerID) onHandshakeRequired?(peerID)
} catch { } catch {
SecureLogger.logError(error, context: "Failed to initiate rekey for peer: \(peerID)", category: SecureLogger.session) SecureLogger.error(error, context: "Failed to initiate rekey for peer: \(peerID)", category: .session)
} }
} }
} }
+171 -174
View File
@@ -1,28 +1,39 @@
import BitLogger
import Foundation import Foundation
import Combine import Combine
// Minimal Nostr transport conforming to Transport for offline sending // Minimal Nostr transport conforming to Transport for offline sending
final class NostrTransport: Transport { final class NostrTransport: Transport {
// 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
init(keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge) {
self.keychain = keychain
self.idBridge = idBridge
}
// MARK: - Transport Protocol Conformance
weak var delegate: BitchatDelegate? weak var delegate: BitchatDelegate?
weak var peerEventsDelegate: TransportPeerEventsDelegate? weak var peerEventsDelegate: TransportPeerEventsDelegate?
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
Just([]).eraseToAnyPublisher() Just([]).eraseToAnyPublisher()
} }
func currentPeerSnapshots() -> [TransportPeerSnapshot] { [] } func currentPeerSnapshots() -> [TransportPeerSnapshot] { [] }
// Provide BLE short peer ID for BitChat embedding var myPeerID: PeerID { senderPeerID }
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 = 0.35 // ~3 per second
var myPeerID: String { senderPeerID }
var myNickname: String { "" } var myNickname: String { "" }
func setNickname(_ nickname: String) { /* not used for Nostr */ } func setNickname(_ nickname: String) { /* not used for Nostr */ }
@@ -30,70 +41,167 @@ final class NostrTransport: Transport {
func stopServices() { /* no-op */ } func stopServices() { /* no-op */ }
func emergencyDisconnectAll() { /* no-op */ } func emergencyDisconnectAll() { /* no-op */ }
func isPeerConnected(_ peerID: String) -> Bool { false } func isPeerConnected(_ peerID: PeerID) -> Bool { false }
func peerNickname(peerID: String) -> String? { nil } func isPeerReachable(_ peerID: PeerID) -> Bool { false }
func getPeerNicknames() -> [String : String] { [:] } func peerNickname(peerID: PeerID) -> String? { nil }
func getPeerNicknames() -> [PeerID : String] { [:] }
func getFingerprint(for peerID: String) -> String? { nil } func getFingerprint(for peerID: PeerID) -> String? { nil }
func getNoiseSessionState(for peerID: String) -> LazyHandshakeState { .none } func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }
func triggerHandshake(with peerID: String) { /* no-op */ } func triggerHandshake(with peerID: PeerID) { /* no-op */ }
// Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation // Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation
private static var cachedNoiseService: NoiseEncryptionService = { private static var cachedNoiseService: NoiseEncryptionService?
NoiseEncryptionService() func getNoiseService() -> NoiseEncryptionService {
}() if let noiseService = Self.cachedNoiseService {
func getNoiseService() -> NoiseEncryptionService { Self.cachedNoiseService } return noiseService
}
let noiseService = NoiseEncryptionService(keychain: keychain)
Self.cachedNoiseService = noiseService
return noiseService
}
// Public broadcast not supported over Nostr here // Public broadcast not supported over Nostr here
func sendMessage(_ content: String, mentions: [String]) { /* no-op */ } func sendMessage(_ content: String, mentions: [String]) { /* no-op */ }
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 Task { @MainActor in
// Resolve favorite by full noise key or by short peerID fallback guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
var recipientNostrPubkey: String? guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
if let noiseKey = Data(hexString: peerID), SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) {
recipientNostrPubkey = fav.peerNostrPublicKey
}
if recipientNostrPubkey == nil, peerID.count == 16 {
recipientNostrPubkey = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNostrPublicKey
}
guard let recipientNpub = recipientNostrPubkey 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)
// Convert recipient npub -> hex (x-only) // Convert recipient npub -> hex (x-only)
let recipientHex: String let recipientHex: String
do { do {
let (hrp, data) = try Bech32.decode(recipientNpub) let (hrp, data) = try Bech32.decode(recipientNpub)
guard hrp == "npub" else { guard hrp == "npub" else {
SecureLogger.log("NostrTransport: recipient key not npub (hrp=\(hrp))", category: SecureLogger.session, level: .error) SecureLogger.error("NostrTransport: recipient key not npub (hrp=\(hrp))", category: .session)
return return
} }
recipientHex = data.hexEncodedString() recipientHex = data.hexEncodedString()
} catch { } catch {
SecureLogger.log("NostrTransport: failed to decode npub -> hex: \(error)", category: SecureLogger.session, level: .error) SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
return return
} }
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else { guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
SecureLogger.log("NostrTransport: failed to embed PM packet", category: SecureLogger.session, level: .error) SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
return return
} }
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else { guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.log("NostrTransport: failed to build Nostr event for PM", category: SecureLogger.session, level: .error) SecureLogger.error("NostrTransport: failed to build Nostr event for PM", category: .session)
return return
} }
SecureLogger.log("NostrTransport: sending PM giftWrap id=\(event.id.prefix(16))", SecureLogger.debug("NostrTransport: sending PM giftWrap id=\(event.id.prefix(16))", category: .session)
category: SecureLogger.session, level: .debug)
NostrRelayManager.shared.sendEvent(event) NostrRelayManager.shared.sendEvent(event)
} }
} }
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) { func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
// Enqueue and process with throttling to avoid relay rate limits // Enqueue and process with throttling to avoid relay rate limits
readQueue.append(QueuedRead(receipt: receipt, peerID: peerID)) readQueue.append(QueuedRead(receipt: receipt, peerID: peerID))
processReadQueueIfNeeded() 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 { return }
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) 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 { return }
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) 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.id) 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.id) 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.id) 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() { private func processReadQueueIfNeeded() {
guard !isSendingReadAcks else { return } guard !isSendingReadAcks else { return }
guard !readQueue.isEmpty else { return } guard !readQueue.isEmpty else { return }
@@ -105,18 +213,9 @@ final class NostrTransport: Transport {
guard !readQueue.isEmpty else { isSendingReadAcks = false; return } guard !readQueue.isEmpty else { isSendingReadAcks = false; return }
let item = readQueue.removeFirst() let item = readQueue.removeFirst()
Task { @MainActor in Task { @MainActor in
var recipientNostrPubkey: String? guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return }
if let noiseKey = Data(hexString: item.peerID), guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) { SecureLogger.debug("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))", category: .session)
recipientNostrPubkey = fav.peerNostrPublicKey
}
if recipientNostrPubkey == nil, item.peerID.count == 16 {
recipientNostrPubkey = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: item.peerID)?.peerNostrPublicKey
}
guard let recipientNpub = recipientNostrPubkey 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)
// Convert recipient npub -> hex // Convert recipient npub -> hex
let recipientHex: String let recipientHex: String
do { do {
@@ -124,16 +223,15 @@ final class NostrTransport: Transport {
guard hrp == "npub" else { scheduleNextReadAck(); return } guard hrp == "npub" else { scheduleNextReadAck(); return }
recipientHex = data.hexEncodedString() recipientHex = data.hexEncodedString()
} catch { scheduleNextReadAck(); return } } catch { scheduleNextReadAck(); return }
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else { guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID.id, senderPeerID: senderPeerID.id) else {
SecureLogger.log("NostrTransport: failed to embed READ ack", category: SecureLogger.session, level: .error) SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
scheduleNextReadAck(); return scheduleNextReadAck(); return
} }
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else { guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.log("NostrTransport: failed to build Nostr event for READ ack", category: SecureLogger.session, level: .error) SecureLogger.error("NostrTransport: failed to build Nostr event for READ ack", category: .session)
scheduleNextReadAck(); return scheduleNextReadAck(); return
} }
SecureLogger.log("NostrTransport: sending READ ack giftWrap id=\(event.id.prefix(16))", SecureLogger.debug("NostrTransport: sending READ ack giftWrap id=\(event.id.prefix(16))", category: .session)
category: SecureLogger.session, level: .debug)
NostrRelayManager.shared.sendEvent(event) NostrRelayManager.shared.sendEvent(event)
scheduleNextReadAck() scheduleNextReadAck()
} }
@@ -147,119 +245,18 @@ final class NostrTransport: Transport {
} }
} }
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) { @MainActor
Task { @MainActor in private func resolveRecipientNpub(for peerID: PeerID) -> String? {
var recipientNostrPubkey: String? if let noiseKey = Data(hexString: peerID.id),
if let noiseKey = Data(hexString: peerID), let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) { let npub = fav.peerNostrPublicKey {
recipientNostrPubkey = fav.peerNostrPublicKey return npub
}
if recipientNostrPubkey == nil, peerID.count == 16 {
recipientNostrPubkey = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNostrPublicKey
}
guard let recipientNpub = recipientNostrPubkey 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)
} }
} if peerID.id.count == 16,
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
func sendBroadcastAnnounce() { /* no-op for Nostr */ } let npub = fav.peerNostrPublicKey {
func sendDeliveryAck(for messageID: String, to peerID: String) { return npub
Task { @MainActor in
var recipientNostrPubkey: String?
if let noiseKey = Data(hexString: peerID),
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) {
recipientNostrPubkey = fav.peerNostrPublicKey
}
if recipientNostrPubkey == nil, peerID.count == 16 {
recipientNostrPubkey = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNostrPublicKey
}
guard let recipientNpub = recipientNostrPubkey 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)
} }
return nil
} }
} }
+10 -21
View File
@@ -14,7 +14,7 @@ import UIKit
import AppKit import AppKit
#endif #endif
class NotificationService { final class NotificationService {
static let shared = NotificationService() static let shared = NotificationService()
private init() {} private init() {}
@@ -62,7 +62,7 @@ class NotificationService {
} }
func sendPrivateMessageNotification(from sender: String, message: String, peerID: String) { func sendPrivateMessageNotification(from sender: String, message: String, peerID: String) {
let title = "🔒 private message from \(sender)" let title = "🔒 DM from \(sender)"
let body = message let body = message
let identifier = "private-\(UUID().uuidString)" let identifier = "private-\(UUID().uuidString)"
let userInfo = ["peerID": peerID, "senderName": sender] let userInfo = ["peerID": peerID, "senderName": sender]
@@ -70,26 +70,15 @@ class NotificationService {
sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo) sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo)
} }
func sendFavoriteOnlineNotification(nickname: String) { // Geohash public chat notification with deep link to a specific geohash
// Send directly without checking app state for favorites func sendGeohashActivityNotification(geohash: String, titlePrefix: String = "#", bodyPreview: String) {
DispatchQueue.main.async { let title = "\(titlePrefix)\(geohash)"
let content = UNMutableNotificationContent() let identifier = "geo-activity-\(geohash)-\(Date().timeIntervalSince1970)"
content.title = "\(nickname) is online!" let deeplink = "bitchat://geohash/\(geohash)"
content.body = "wanna get in there?" let userInfo: [String: Any] = ["deeplink": deeplink]
content.sound = .default sendLocalNotification(title: title, body: bodyPreview, identifier: identifier, userInfo: userInfo)
let request = UNNotificationRequest(
identifier: "favorite-online-\(UUID().uuidString)",
content: content,
trigger: nil
)
UNUserNotificationCenter.current().add(request) { _ in
// Notification added
}
}
} }
func sendNetworkAvailableNotification(peerCount: Int) { func sendNetworkAvailableNotification(peerCount: Int) {
let title = "👥 bitchatters nearby!" let title = "👥 bitchatters nearby!"
let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around" let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around"
@@ -0,0 +1,81 @@
//
// NotificationStreamAssembler.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
struct NotificationStreamAssembler {
private var buffer = Data()
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 reset = false
let maxFrameLength = TransportConfig.blePendingWriteBufferCapBytes
let minHeaderBytes = 14 // version + type + ttl + timestamp(8) + flags + length(2)
let minFramePrefix = minHeaderBytes + BinaryProtocol.senderIDSize
while buffer.count >= minFramePrefix {
guard let first = buffer.first else { break }
if first != 1 {
dropped.append(buffer.removeFirst())
continue
}
guard buffer.count >= minHeaderBytes else { break }
let headerBytes = Array(buffer.prefix(minFramePrefix))
guard headerBytes.count == minFramePrefix else { break }
let flags = headerBytes[11]
let hasRecipient = (flags & BinaryProtocol.Flags.hasRecipient) != 0
let hasSignature = (flags & BinaryProtocol.Flags.hasSignature) != 0
let payloadLen = (Int(headerBytes[12]) << 8) | Int(headerBytes[13])
var frameLength = minFramePrefix + payloadLen
if hasRecipient { frameLength += BinaryProtocol.recipientIDSize }
if hasSignature { frameLength += BinaryProtocol.signatureSize }
guard frameLength > 0, frameLength <= maxFrameLength else {
buffer.removeAll()
reset = true
break
}
if buffer.count < frameLength {
// Check if a new frame start exists within the incomplete buffer; if so, drop leading partial bytes.
if let nextStart = buffer.dropFirst().firstIndex(of: 1) {
let dropCount = buffer.distance(from: buffer.startIndex, to: nextStart)
if dropCount > 0 {
buffer.removeFirst(dropCount)
dropped.append(1) // treat as dropped partial start
}
}
break
}
let frame = Data(buffer.prefix(frameLength))
frames.append(frame)
buffer.removeFirst(frameLength)
}
if !buffer.isEmpty, buffer.allSatisfy({ $0 == 0 }) {
buffer.removeAll(keepingCapacity: false)
}
return (frames, dropped, reset)
}
mutating func reset() {
buffer.removeAll(keepingCapacity: false)
}
}
+24 -142
View File
@@ -6,14 +6,15 @@
// This is free and unencumbered software released into the public domain. // This is free and unencumbered software released into the public domain.
// //
import BitLogger
import Foundation import Foundation
import SwiftUI import SwiftUI
/// Manages all private chat functionality /// Manages all private chat functionality
class PrivateChatManager: ObservableObject { final class PrivateChatManager: ObservableObject {
@Published var privateChats: [String: [BitchatMessage]] = [:] @Published var privateChats: [PeerID: [BitchatMessage]] = [:]
@Published var selectedPeer: String? = nil @Published var selectedPeer: PeerID? = nil
@Published var unreadMessages: Set<String> = [] @Published var unreadMessages: Set<PeerID> = []
private var selectedPeerFingerprint: String? = nil private var selectedPeerFingerprint: String? = nil
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
@@ -27,10 +28,10 @@ class PrivateChatManager: ObservableObject {
} }
// Cap for messages stored per private chat // Cap for messages stored per private chat
private let privateChatCap = 1337 private let privateChatCap = TransportConfig.privateChatCap
/// Start a private chat with a peer /// Start a private chat with a peer
func startChat(with peerID: String) { func startChat(with peerID: PeerID) {
selectedPeer = peerID selectedPeer = peerID
// Store fingerprint for persistence across reconnections // Store fingerprint for persistence across reconnections
@@ -52,109 +53,33 @@ class PrivateChatManager: ObservableObject {
selectedPeer = nil selectedPeer = nil
selectedPeerFingerprint = nil selectedPeerFingerprint = nil
} }
/// Send a private message
func sendMessage(_ content: String, to peerID: String) {
guard let meshService = meshService,
let peerNickname = meshService.peerNickname(peerID: peerID) else {
return
}
let messageID = UUID().uuidString
// Create local message
let message = BitchatMessage(
id: messageID,
sender: meshService.myNickname,
content: content,
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: peerNickname,
senderPeerID: meshService.myPeerID,
mentions: nil,
deliveryStatus: .sending
)
// Add to chat
if privateChats[peerID] == nil { privateChats[peerID] = [] }
privateChats[peerID]?.append(message)
// Enforce per-chat cap on local append
if var arr = privateChats[peerID], arr.count > privateChatCap {
let remove = arr.count - privateChatCap
arr.removeFirst(remove)
privateChats[peerID] = arr
}
// Send via mesh service
meshService.sendPrivateMessage(content, to: peerID, recipientNickname: peerNickname, messageID: messageID)
}
/// Handle incoming private message
func handleIncomingMessage(_ message: BitchatMessage) {
guard let senderPeerID = message.senderPeerID else { return }
// Initialize chat if needed
if privateChats[senderPeerID] == nil {
privateChats[senderPeerID] = []
}
// Deduplicate by ID: replace existing message if present, else append
if let idx = privateChats[senderPeerID]?.firstIndex(where: { $0.id == message.id }) {
privateChats[senderPeerID]?[idx] = message
} else {
privateChats[senderPeerID]?.append(message)
}
// Sanitize chat to avoid duplicate IDs and sort by timestamp
sanitizeChat(for: senderPeerID)
// Enforce cap after sanitize
if var arr = privateChats[senderPeerID], arr.count > privateChatCap {
let remove = arr.count - privateChatCap
arr.removeFirst(remove)
privateChats[senderPeerID] = arr
}
// Mark as unread if not in this chat
if selectedPeer != senderPeerID {
unreadMessages.insert(senderPeerID)
// Avoid notifying for messages already marked as read (dup/resubscribe cases)
if !sentReadReceipts.contains(message.id) {
NotificationService.shared.sendPrivateMessageNotification(
from: message.sender,
message: message.content,
peerID: senderPeerID
)
}
} else {
// Send read receipt if viewing this chat
sendReadReceipt(for: message)
}
}
/// Remove duplicate messages by ID and keep chronological order /// Remove duplicate messages by ID and keep chronological order
func sanitizeChat(for peerID: String) { func sanitizeChat(for peerID: PeerID) {
guard let arr = privateChats[peerID] else { return } guard let arr = privateChats[peerID] else { return }
var seen = Set<String>() if arr.count <= 1 {
return
}
var indexByID: [String: Int] = [:]
indexByID.reserveCapacity(arr.count)
var deduped: [BitchatMessage] = [] var deduped: [BitchatMessage] = []
deduped.reserveCapacity(arr.count)
for msg in arr.sorted(by: { $0.timestamp < $1.timestamp }) { for msg in arr.sorted(by: { $0.timestamp < $1.timestamp }) {
if !seen.contains(msg.id) { if let existing = indexByID[msg.id] {
seen.insert(msg.id) deduped[existing] = msg
deduped.append(msg)
} else { } else {
// Replace previous with the latest occurrence (which is later in sort) indexByID[msg.id] = deduped.count
if let index = deduped.firstIndex(where: { $0.id == msg.id }) { deduped.append(msg)
deduped[index] = msg
}
} }
} }
privateChats[peerID] = deduped privateChats[peerID] = deduped
} }
/// Mark messages from a peer as read /// Mark messages from a peer as read
func markAsRead(from peerID: String) { func markAsRead(from peerID: PeerID) {
unreadMessages.remove(peerID) unreadMessages.remove(peerID)
// Send read receipts for unread messages that haven't been sent yet // Send read receipts for unread messages that haven't been sent yet
@@ -167,48 +92,6 @@ class PrivateChatManager: ObservableObject {
} }
} }
/// Update the selected peer if fingerprint matches (for reconnections)
func updateSelectedPeer(peers: [String: String]) {
guard let fingerprint = selectedPeerFingerprint else { return }
// Find peer with matching fingerprint
for (peerID, _) in peers {
if meshService?.getFingerprint(for: peerID) == fingerprint {
selectedPeer = peerID
break
}
}
}
/// Get chat messages for current context
func getCurrentMessages() -> [BitchatMessage] {
guard let peer = selectedPeer else { return [] }
return privateChats[peer] ?? []
}
/// Clear a private chat
func clearChat(with peerID: String) {
privateChats[peerID]?.removeAll()
}
/// Handle delivery acknowledgment
func handleDeliveryAck(messageID: String, from peerID: String) {
guard privateChats[peerID] != nil else { return }
if let index = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
privateChats[peerID]?[index].deliveryStatus = .delivered(to: "recipient", at: Date())
}
}
/// Handle read receipt
func handleReadReceipt(messageID: String, from peerID: String) {
guard privateChats[peerID] != nil else { return }
if let index = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
privateChats[peerID]?[index].deliveryStatus = .read(by: "recipient", at: Date())
}
}
// MARK: - Private Methods // MARK: - Private Methods
private func sendReadReceipt(for message: BitchatMessage) { private func sendReadReceipt(for message: BitchatMessage) {
@@ -222,14 +105,13 @@ class PrivateChatManager: ObservableObject {
// Create read receipt using the simplified method // Create read receipt using the simplified method
let receipt = ReadReceipt( let receipt = ReadReceipt(
originalMessageID: message.id, originalMessageID: message.id,
readerID: meshService?.myPeerID ?? "", readerID: meshService?.myPeerID.id ?? "",
readerNickname: meshService?.myNickname ?? "" readerNickname: meshService?.myNickname ?? ""
) )
// Route via MessageRouter to avoid handshakeRequired spam when session isn't established // Route via MessageRouter to avoid handshakeRequired spam when session isn't established
if let router = messageRouter { if let router = messageRouter {
SecureLogger.log("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.prefix(8))… via router", SecureLogger.debug("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.id.prefix(8))… via router", category: .session)
category: SecureLogger.session, level: .debug)
Task { @MainActor in Task { @MainActor in
router.sendReadReceipt(receipt, to: senderPeerID) router.sendReadReceipt(receipt, to: senderPeerID)
} }
+37 -22
View File
@@ -12,36 +12,51 @@ struct RelayController {
static func decide(ttl: UInt8, static func decide(ttl: UInt8,
senderIsSelf: Bool, senderIsSelf: Bool,
isEncrypted: Bool, isEncrypted: Bool,
isDirectedEncrypted: Bool,
isDirectedFragment: Bool, isDirectedFragment: Bool,
isHandshake: Bool, isHandshake: Bool,
isAnnounce: Bool,
degree: Int, degree: Int,
highDegreeThreshold: Int) -> RelayDecision { highDegreeThreshold: Int) -> RelayDecision {
let ttlCap = min(ttl, TransportConfig.messageTTLDefault)
// Suppress obvious non-relays // Suppress obvious non-relays
if ttl <= 1 || senderIsSelf { return RelayDecision(shouldRelay: false, newTTL: ttl, delayMs: 0) } if ttlCap <= 1 || senderIsSelf {
return RelayDecision(shouldRelay: false, newTTL: ttlCap, delayMs: 0)
// Degree-aware probability to reduce floods in dense graphs
let baseProb: Double
switch degree {
case 0...2: baseProb = 1.0
case 3...4: baseProb = 0.9
case 5...6: baseProb = 0.7
case 7...9: baseProb = 0.55
default: baseProb = 0.45
} }
var prob = baseProb
if isHandshake { prob = max(0.3, baseProb - 0.2) }
// Sample a forwarding decision // For session-critical or directed traffic, be deterministic and reliable
let shouldRelay = Double.random(in: 0...1) <= prob if isHandshake || isDirectedFragment || isDirectedEncrypted {
// Always relay with no TTL cap for these types
let newTTL = ttlCap &- 1
// Slight jitter to desynchronize without adding too much latency
// Tighter for faster multi-hop handshakes and directed DMs
let delayRange: ClosedRange<Int> = isHandshake ? 10...35 : 20...60
let delayMs = Int.random(in: delayRange)
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
}
// TTL clamping in dense graphs // TTL clamping for broadcast
let ttlCap: UInt8 = degree >= highDegreeThreshold ? 3 : 5 // - Dense graphs: keep lower but still allow multi-hop bridging
let clamped = max(1, min(ttl, ttlCap)) // - Announces get a bit more headroom
let newTTL = clamped &- 1 let ttlLimit: UInt8 = {
if degree >= highDegreeThreshold {
return max(UInt8(2), min(ttlCap, UInt8(5)))
}
let preferred = UInt8(isAnnounce ? 7 : 6)
return max(UInt8(2), min(ttlCap, preferred))
}()
let newTTL = ttlLimit &- 1
// Short jitter to desynchronize rebroadcasts // Wider jitter window to allow duplicate suppression to win more often
let delayMs = Int.random(in: 20...80) // For sparse graphs (<=2), relay quickly to avoid cancellation races
return RelayDecision(shouldRelay: shouldRelay, newTTL: newTTL, delayMs: delayMs) let delayMs: Int
switch degree {
case 0...2: delayMs = Int.random(in: 10...40)
case 3...5: delayMs = Int.random(in: 60...150)
case 6...9: delayMs = Int.random(in: 80...180)
default: delayMs = Int.random(in: 100...220)
}
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
} }
} }
+28 -18
View File
@@ -3,8 +3,8 @@ import Combine
/// Abstract transport interface used by ChatViewModel and services. /// Abstract transport interface used by ChatViewModel and services.
/// BLEService implements this protocol; a future Nostr transport can too. /// BLEService implements this protocol; a future Nostr transport can too.
struct TransportPeerSnapshot { struct TransportPeerSnapshot: Equatable, Hashable {
let id: String let peerID: PeerID
let nickname: String let nickname: String
let isConnected: Bool let isConnected: Bool
let noisePublicKey: Data? let noisePublicKey: Data?
@@ -12,13 +12,17 @@ struct TransportPeerSnapshot {
} }
protocol Transport: AnyObject { protocol Transport: AnyObject {
// Peer events (preferred over publishers for UI)
var peerEventsDelegate: TransportPeerEventsDelegate? { get set }
// Event sink // Event sink
var delegate: BitchatDelegate? { get set } var delegate: BitchatDelegate? { get set }
// Peer events (preferred over publishers for UI)
var peerEventsDelegate: TransportPeerEventsDelegate? { get set }
// Peer snapshots (for non-UI services)
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get }
func currentPeerSnapshots() -> [TransportPeerSnapshot]
// Identity // Identity
var myPeerID: String { get } var myPeerID: PeerID { get }
var myNickname: String { get } var myNickname: String { get }
func setNickname(_ nickname: String) func setNickname(_ nickname: String)
@@ -28,27 +32,33 @@ protocol Transport: AnyObject {
func emergencyDisconnectAll() func emergencyDisconnectAll()
// Connectivity and peers // Connectivity and peers
func isPeerConnected(_ peerID: String) -> Bool func isPeerConnected(_ peerID: PeerID) -> Bool
func peerNickname(peerID: String) -> String? func isPeerReachable(_ peerID: PeerID) -> Bool
func getPeerNicknames() -> [String: String] func peerNickname(peerID: PeerID) -> String?
func getPeerNicknames() -> [PeerID: String]
// Protocol utilities // Protocol utilities
func getFingerprint(for peerID: String) -> String? func getFingerprint(for peerID: PeerID) -> String?
func getNoiseSessionState(for peerID: String) -> LazyHandshakeState func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState
func triggerHandshake(with peerID: String) func triggerHandshake(with peerID: PeerID)
func getNoiseService() -> NoiseEncryptionService func getNoiseService() -> NoiseEncryptionService
// Messaging // Messaging
func sendMessage(_ content: String, mentions: [String]) func sendMessage(_ content: String, mentions: [String])
func sendPrivateMessage(_ content: String, to peerID: String, recipientNickname: String, messageID: String) func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String)
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
func sendBroadcastAnnounce() func sendBroadcastAnnounce()
func sendDeliveryAck(for messageID: String, to peerID: String) func sendDeliveryAck(for messageID: String, to peerID: PeerID)
// Peer snapshots (for non-UI services) // QR verification (optional for transports)
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get } func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
func currentPeerSnapshots() -> [TransportPeerSnapshot] func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
}
extension Transport {
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
} }
protocol TransportPeerEventsDelegate: AnyObject { protocol TransportPeerEventsDelegate: AnyObject {
+194
View File
@@ -0,0 +1,194 @@
import Foundation
/// Centralized knobs for transport- and UI-related limits.
/// Keep values aligned with existing behavior when replacing magic numbers.
enum TransportConfig {
// BLE / Protocol
static let bleDefaultFragmentSize: Int = 469 // ~512 MTU minus protocol overhead
static let messageTTLDefault: UInt8 = 7 // Default TTL for mesh flooding
static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies
static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays
// UI / Storage Caps
static let privateChatCap: Int = 1337
static let meshTimelineCap: Int = 1337
static let geoTimelineCap: Int = 1337
static let contentLRUCap: Int = 2000
// Timers
static let networkResetGraceSeconds: TimeInterval = 600 // 10 minutes
static let basePublicFlushInterval: TimeInterval = 0.08 // ~12.5 fps batching
// BLE duty/announce/connect
static let bleConnectRateLimitInterval: TimeInterval = 0.5
static let bleMaxCentralLinks: Int = 6
static let bleDutyOnDuration: TimeInterval = 5.0
static let bleDutyOffDuration: TimeInterval = 10.0
static let bleAnnounceMinInterval: TimeInterval = 1.0
// BLE discovery/quality thresholds
static let bleDynamicRSSIThresholdDefault: Int = -90
static let bleConnectionCandidatesMax: Int = 100
static let blePendingWriteBufferCapBytes: Int = 1_000_000
static let blePendingNotificationsCapCount: Int = 20
// Nostr
static let nostrReadAckInterval: TimeInterval = 0.35 // ~3 per second
// UI thresholds
static let uiLateInsertThreshold: TimeInterval = 15.0
// Geohash public chats are more sensitive to ordering; use a tighter threshold
static let uiLateInsertThresholdGeo: TimeInterval = 0.0
static let uiProcessedNostrEventsCap: Int = 2000
static let uiChannelInactivityThresholdSeconds: TimeInterval = 9 * 60
// UI rate limiters (token buckets)
static let uiSenderRateBucketCapacity: Double = 5
static let uiSenderRateBucketRefillPerSec: Double = 1.0
static let uiContentRateBucketCapacity: Double = 3
static let uiContentRateBucketRefillPerSec: Double = 0.5
// UI sleeps/delays
static let uiStartupInitialDelaySeconds: TimeInterval = 1.0
static let uiStartupShortSleepNs: UInt64 = 200_000_000
static let uiStartupPhaseDurationSeconds: TimeInterval = 2.0
static let uiAsyncShortSleepNs: UInt64 = 100_000_000
static let uiAsyncMediumSleepNs: UInt64 = 500_000_000
static let uiReadReceiptRetryShortSeconds: TimeInterval = 0.1
static let uiReadReceiptRetryLongSeconds: TimeInterval = 0.5
static let uiBatchDispatchStaggerSeconds: TimeInterval = 0.15
static let uiScrollThrottleSeconds: TimeInterval = 0.5
static let uiAnimationShortSeconds: TimeInterval = 0.15
static let uiAnimationMediumSeconds: TimeInterval = 0.2
static let uiAnimationSidebarSeconds: TimeInterval = 0.25
static let uiRecentCutoffFiveMinutesSeconds: TimeInterval = 5 * 60
// BLE maintenance & thresholds
static let bleMaintenanceInterval: TimeInterval = 5.0
static let bleMaintenanceLeewaySeconds: Int = 1
static let bleIsolationRelaxThresholdSeconds: TimeInterval = 60
static let bleRecentTimeoutWindowSeconds: TimeInterval = 60
static let bleRecentTimeoutCountThreshold: Int = 3
static let bleRSSIIsolatedBase: Int = -90
static let bleRSSIIsolatedRelaxed: Int = -92
static let bleRSSIConnectedThreshold: Int = -85
static let bleRSSIHighTimeoutThreshold: Int = -80
// How long without seeing traffic before we sanity-check the direct link
// Lowered to make connectedreachable icon changes react faster when walking out of range
static let blePeerInactivityTimeoutSeconds: TimeInterval = 8.0
// How long to retain a peer as "reachable" (not directly connected) since lastSeen
static let bleReachabilityRetentionVerifiedSeconds: TimeInterval = 21.0 // 21s for verified/favorites
static let bleReachabilityRetentionUnverifiedSeconds: TimeInterval = 21.0 // 21s for unknown/unverified
static let bleFragmentLifetimeSeconds: TimeInterval = 30.0
static let bleIngressRecordLifetimeSeconds: TimeInterval = 3.0
static let bleConnectTimeoutBackoffWindowSeconds: TimeInterval = 120.0
static let bleRecentPacketWindowSeconds: TimeInterval = 30.0
static let bleRecentPacketWindowMaxCount: Int = 100
// Keep scanning fully ON when we saw traffic very recently
static let bleRecentTrafficForceScanSeconds: TimeInterval = 10.0
static let bleThreadSleepWriteShortDelaySeconds: TimeInterval = 0.05
static let bleExpectedWritePerFragmentMs: Int = 8
static let bleExpectedWriteMaxMs: Int = 2000
// Faster fragment pacing; use slightly tighter spacing for directed trains
static let bleFragmentSpacingMs: Int = 5
static let bleFragmentSpacingDirectedMs: Int = 4
static let bleAnnounceIntervalSeconds: TimeInterval = 4.0
static let bleDutyOnDurationDense: TimeInterval = 3.0
static let bleDutyOffDurationDense: TimeInterval = 15.0
static let bleConnectedAnnounceBaseSecondsDense: TimeInterval = 30.0
static let bleConnectedAnnounceBaseSecondsSparse: TimeInterval = 15.0
static let bleConnectedAnnounceJitterDense: TimeInterval = 8.0
static let bleConnectedAnnounceJitterSparse: TimeInterval = 4.0
// Location
static let locationDistanceFilterMeters: Double = 1000
// Live (channel sheet open) distance threshold for meaningful updates
static let locationDistanceFilterLiveMeters: Double = 10.0
static let locationLiveRefreshInterval: TimeInterval = 5.0
// Notifications (geohash)
static let uiGeoNotifyCooldownSeconds: TimeInterval = 60.0
static let uiGeoNotifySnippetMaxLen: Int = 80
// Nostr geohash
static let nostrGeohashInitialLookbackSeconds: TimeInterval = 3600
static let nostrGeohashInitialLimit: Int = 200
static let nostrGeoRelayCount: Int = 5
static let nostrGeohashSampleLookbackSeconds: TimeInterval = 300
static let nostrGeohashSampleLimit: Int = 100
static let nostrDMSubscribeLookbackSeconds: TimeInterval = 86400
// Nostr helpers
static let nostrShortKeyDisplayLength: Int = 8
static let nostrConvKeyPrefixLength: Int = 16
// Compression
static let compressionThresholdBytes: Int = 100
// Message deduplication
static let messageDedupMaxAgeSeconds: TimeInterval = 300
static let messageDedupMaxCount: Int = 1000
// Verification QR
static let verificationQRMaxAgeSeconds: TimeInterval = 5 * 60
// Nostr relay backoff
static let nostrRelayInitialBackoffSeconds: TimeInterval = 1.0
static let nostrRelayMaxBackoffSeconds: TimeInterval = 300.0
static let nostrRelayBackoffMultiplier: Double = 2.0
static let nostrRelayMaxReconnectAttempts: Int = 10
static let nostrRelayDefaultFetchLimit: Int = 100
// Geo relay directory
static let geoRelayFetchIntervalSeconds: TimeInterval = 60 * 60 * 24
// BLE operational delays
static let bleInitialAnnounceDelaySeconds: TimeInterval = 0.6
static let bleConnectTimeoutSeconds: TimeInterval = 8.0
static let bleRestartScanDelaySeconds: TimeInterval = 0.1
static let blePostSubscribeAnnounceDelaySeconds: TimeInterval = 0.05
static let blePostAnnounceDelaySeconds: TimeInterval = 0.4
static let bleForceAnnounceMinIntervalSeconds: TimeInterval = 0.15
// Store-and-forward for directed packets at relays
static let bleDirectedSpoolWindowSeconds: TimeInterval = 15.0
// Log/UI debounce windows
// Shorter debounce so UI reacts faster while still suppressing duplicate callbacks
static let bleDisconnectNotifyDebounceSeconds: TimeInterval = 0.9
static let bleReconnectLogDebounceSeconds: TimeInterval = 2.0
// Weak-link cooldown after connection timeouts
static let bleWeakLinkCooldownSeconds: TimeInterval = 30.0
static let bleWeakLinkRSSICutoff: Int = -90
// Content hashing / formatting
static let contentKeyPrefixLength: Int = 256
static let uiLongMessageLengthThreshold: Int = 2000
static let uiVeryLongTokenThreshold: Int = 512
static let uiLongMessageLineLimit: Int = 30
static let uiFingerprintSampleCount: Int = 3
// UI swipe/gesture thresholds
static let uiBackSwipeTranslationLarge: CGFloat = 50
static let uiBackSwipeTranslationSmall: CGFloat = 30
static let uiBackSwipeVelocityThreshold: CGFloat = 300
// UI color tuning
static let uiColorHueAvoidanceDelta: Double = 0.05
static let uiColorHueOffset: Double = 0.12
// Peer list palette
static let uiPeerPaletteSlots: Int = 36
static let uiPeerPaletteRingBrightnessDeltaLight: Double = 0.07
static let uiPeerPaletteRingBrightnessDeltaDark: Double = -0.07
// UI windowing (infinite scroll)
static let uiWindowInitialCountPublic: Int = 300
static let uiWindowInitialCountPrivate: Int = 300
static let uiWindowStepCount: Int = 200
// Share extension
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 2.0
static let uiShareAcceptWindowSeconds: TimeInterval = 30.0
static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60
}
+84 -128
View File
@@ -6,34 +6,43 @@
// This is free and unencumbered software released into the public domain. // This is free and unencumbered software released into the public domain.
// //
import BitLogger
import Foundation import Foundation
import Combine import Combine
import SwiftUI import SwiftUI
import CryptoKit
/// Single source of truth for peer state, combining mesh connectivity and favorites /// Single source of truth for peer state, combining mesh connectivity and favorites
@MainActor @MainActor
class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
// MARK: - Published Properties // MARK: - Published Properties
@Published private(set) var peers: [BitchatPeer] = [] @Published private(set) var peers: [BitchatPeer] = []
@Published private(set) var connectedPeerIDs: Set<String> = [] @Published private(set) var connectedPeerIDs: Set<PeerID> = []
@Published private(set) var favorites: [BitchatPeer] = [] @Published private(set) var favorites: [BitchatPeer] = []
@Published private(set) var mutualFavorites: [BitchatPeer] = [] @Published private(set) var mutualFavorites: [BitchatPeer] = []
// MARK: - Private Properties // MARK: - Private Properties
private var peerIndex: [String: BitchatPeer] = [:] private var peerIndex: [PeerID: BitchatPeer] = [:]
private var fingerprintCache: [String: String] = [:] // peerID -> fingerprint private var fingerprintCache: [PeerID: String] = [:]
private let meshService: Transport private let meshService: Transport
private let idBridge: NostrIdentityBridge
private let identityManager: SecureIdentityStateManagerProtocol
weak var messageRouter: MessageRouter?
private let favoritesService = FavoritesPersistenceService.shared private let favoritesService = FavoritesPersistenceService.shared
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
// MARK: - Initialization // MARK: - Initialization
init(meshService: Transport) { init(
meshService: Transport,
idBridge: NostrIdentityBridge,
identityManager: SecureIdentityStateManagerProtocol
) {
self.meshService = meshService self.meshService = meshService
self.idBridge = idBridge
self.identityManager = identityManager
// Subscribe to changes from both services // Subscribe to changes from both services
setupSubscriptions() setupSubscriptions()
@@ -68,24 +77,28 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
private func updatePeers() { private func updatePeers() {
let meshPeers = meshService.currentPeerSnapshots() let meshPeers = meshService.currentPeerSnapshots()
// If we have no direct links at all, peers should not be marked reachable
// "Reachable" means mesh-attached via at least one live link.
let hasAnyConnected = meshPeers.contains { $0.isConnected }
let favorites = favoritesService.favorites let favorites = favoritesService.favorites
var enrichedPeers: [BitchatPeer] = [] var enrichedPeers: [BitchatPeer] = []
var connected: Set<String> = [] var connected: Set<PeerID> = []
var addedPeerIDs: Set<String> = [] var addedPeerIDs: Set<PeerID> = []
// Phase 1: Add all connected mesh peers // Phase 1: Add all mesh peers (connected and reachable)
for peerInfo in meshPeers where peerInfo.isConnected { for peerInfo in meshPeers {
let peerID = peerInfo.id let peerID = peerInfo.peerID
guard peerID != meshService.myPeerID else { continue } // Never add self guard peerID != meshService.myPeerID else { continue } // Never add self
let peer = buildPeerFromMesh( let peer = buildPeerFromMesh(
peerInfo: peerInfo, peerInfo: peerInfo,
favorites: favorites favorites: favorites,
meshAttached: hasAnyConnected
) )
enrichedPeers.append(peer) enrichedPeers.append(peer)
connected.insert(peerID) if peer.isConnected { connected.insert(peerID) }
addedPeerIDs.insert(peerID) addedPeerIDs.insert(peerID)
// Update fingerprint cache // Update fingerprint cache
@@ -96,7 +109,7 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
// Phase 2: Add offline favorites that we actively favorite // Phase 2: Add offline favorites that we actively favorite
for (favoriteKey, favorite) in favorites where favorite.isFavorite { for (favoriteKey, favorite) in favorites where favorite.isFavorite {
let peerID = favoriteKey.hexEncodedString() let peerID = PeerID(hexData: favoriteKey)
// Skip if already added (connected peer) // Skip if already added (connected peer)
if addedPeerIDs.contains(peerID) { continue } if addedPeerIDs.contains(peerID) { continue }
@@ -117,14 +130,12 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
// Phase 3: Sort peers // Phase 3: Sort peers
enrichedPeers.sort { lhs, rhs in enrichedPeers.sort { lhs, rhs in
// Connected first // Connectivity rank: connected > reachable > others
if lhs.isConnected != rhs.isConnected { func rank(_ p: BitchatPeer) -> Int { p.isConnected ? 2 : (p.isReachable ? 1 : 0) }
return lhs.isConnected let lr = rank(lhs), rr = rank(rhs)
} if lr != rr { return lr > rr }
// Then favorites // Then favorites inside same rank
if lhs.isFavorite != rhs.isFavorite { if lhs.isFavorite != rhs.isFavorite { return lhs.isFavorite }
return lhs.isFavorite
}
// Finally alphabetical // Finally alphabetical
return lhs.displayName < rhs.displayName return lhs.displayName < rhs.displayName
} }
@@ -132,10 +143,10 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
// Phase 4: Build subsets and indices // Phase 4: Build subsets and indices
var favoritesList: [BitchatPeer] = [] var favoritesList: [BitchatPeer] = []
var mutualsList: [BitchatPeer] = [] var mutualsList: [BitchatPeer] = []
var newIndex: [String: BitchatPeer] = [:] var newIndex: [PeerID: BitchatPeer] = [:]
for peer in enrichedPeers { for peer in enrichedPeers {
newIndex[peer.id] = peer newIndex[peer.peerID] = peer
if peer.isFavorite { if peer.isFavorite {
favoritesList.append(peer) favoritesList.append(peer)
@@ -145,8 +156,11 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
} }
} }
// Phase 5: Update published properties // Phase 5: Filter out offline non-mutual peers and update published properties
self.peers = enrichedPeers let filtered = enrichedPeers.filter { p in
p.isConnected || p.isReachable || p.isMutualFavorite
}
self.peers = filtered
self.connectedPeerIDs = connected self.connectedPeerIDs = connected
self.favorites = favoritesList self.favorites = favoritesList
self.mutualFavorites = mutualsList self.mutualFavorites = mutualsList
@@ -162,14 +176,26 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
private func buildPeerFromMesh( private func buildPeerFromMesh(
peerInfo: TransportPeerSnapshot, peerInfo: TransportPeerSnapshot,
favorites: [Data: FavoritesPersistenceService.FavoriteRelationship] favorites: [Data: FavoritesPersistenceService.FavoriteRelationship],
meshAttached: Bool
) -> BitchatPeer { ) -> BitchatPeer {
// Determine reachability based on lastSeen and identity trust
let now = Date()
let fingerprint = peerInfo.noisePublicKey?.sha256Fingerprint()
let isVerified = fingerprint.map { identityManager.isVerified(fingerprint: $0) } ?? false
let isFav = peerInfo.noisePublicKey.flatMap { favorites[$0]?.isFavorite } ?? false
let retention: TimeInterval = (isVerified || isFav) ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds
// A peer is reachable if we recently saw them AND we are attached to the mesh
let withinRetention = now.timeIntervalSince(peerInfo.lastSeen) <= retention
let isReachable = peerInfo.isConnected ? true : (withinRetention && meshAttached)
var peer = BitchatPeer( var peer = BitchatPeer(
id: peerInfo.id, peerID: peerInfo.peerID,
noisePublicKey: peerInfo.noisePublicKey ?? Data(), noisePublicKey: peerInfo.noisePublicKey ?? Data(),
nickname: peerInfo.nickname, nickname: peerInfo.nickname,
lastSeen: peerInfo.lastSeen, lastSeen: peerInfo.lastSeen,
isConnected: true isConnected: peerInfo.isConnected,
isReachable: isReachable
) )
// Check for favorite status // Check for favorite status
@@ -177,31 +203,6 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
let favoriteStatus = favorites[noiseKey] { let favoriteStatus = favorites[noiseKey] {
peer.favoriteStatus = favoriteStatus peer.favoriteStatus = favoriteStatus
peer.nostrPublicKey = favoriteStatus.peerNostrPublicKey peer.nostrPublicKey = favoriteStatus.peerNostrPublicKey
} else {
// Check by nickname for reconnected peers
let favoriteByNickname = favorites.values.first {
$0.peerNickname == peerInfo.nickname
}
if let favorite = favoriteByNickname,
let noiseKey = peerInfo.noisePublicKey {
SecureLogger.log(
"🔄 Found favorite for '\(peerInfo.nickname)' by nickname, updating noise key",
category: SecureLogger.session,
level: .debug
)
// Update the favorite's key in persistence
favoritesService.updateNoisePublicKey(
from: favorite.peerNoisePublicKey,
to: noiseKey,
peerNickname: peerInfo.nickname
)
// Get updated favorite
peer.favoriteStatus = favoritesService.getFavoriteStatus(for: noiseKey)
peer.nostrPublicKey = peer.favoriteStatus?.peerNostrPublicKey ?? favorite.peerNostrPublicKey
}
} }
return peer return peer
@@ -209,14 +210,15 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
private func buildPeerFromFavorite( private func buildPeerFromFavorite(
favorite: FavoritesPersistenceService.FavoriteRelationship, favorite: FavoritesPersistenceService.FavoriteRelationship,
peerID: String peerID: PeerID
) -> BitchatPeer { ) -> BitchatPeer {
var peer = BitchatPeer( var peer = BitchatPeer(
id: peerID, peerID: peerID,
noisePublicKey: favorite.peerNoisePublicKey, noisePublicKey: favorite.peerNoisePublicKey,
nickname: favorite.peerNickname, nickname: favorite.peerNickname,
lastSeen: favorite.lastUpdated, lastSeen: favorite.lastUpdated,
isConnected: false isConnected: false,
isReachable: false
) )
peer.favoriteStatus = favorite peer.favoriteStatus = favorite
@@ -228,32 +230,27 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
// MARK: - Public Methods // MARK: - Public Methods
/// Get peer by ID /// Get peer by ID
func getPeer(by id: String) -> BitchatPeer? { func getPeer(by peerID: PeerID) -> BitchatPeer? {
return peerIndex[id] return peerIndex[peerID]
} }
/// Get peer ID for nickname /// Get peer ID for nickname
func getPeerID(for nickname: String) -> String? { func getPeerID(for nickname: String) -> PeerID? {
for peer in peers { for peer in peers {
if peer.displayName == nickname || peer.nickname == nickname { if peer.displayName == nickname || peer.nickname == nickname {
return peer.id return peer.peerID
} }
} }
return nil return nil
} }
/// Check if peer is online
func isOnline(_ peerID: String) -> Bool {
return connectedPeerIDs.contains(peerID)
}
/// Check if peer is blocked /// Check if peer is blocked
func isBlocked(_ peerID: String) -> Bool { func isBlocked(_ peerID: PeerID) -> Bool {
// Get fingerprint // Get fingerprint
guard let fingerprint = getFingerprint(for: peerID) else { return false } guard let fingerprint = getFingerprint(for: peerID) else { return false }
// Check SecureIdentityStateManager for block status // Check SecureIdentityStateManager for block status
if let identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprint) { if let identity = identityManager.getSocialIdentity(for: fingerprint) {
return identity.isBlocked return identity.isBlocked
} }
@@ -261,10 +258,9 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
} }
/// Toggle favorite status /// Toggle favorite status
func toggleFavorite(_ peerID: String) { func toggleFavorite(_ peerID: PeerID) {
guard let peer = getPeer(by: peerID) else { guard let peer = getPeer(by: peerID) else {
SecureLogger.log("⚠️ Cannot toggle favorite - peer not found: \(peerID)", SecureLogger.warning("⚠️ Cannot toggle favorite - peer not found: \(peerID)", category: .session)
category: SecureLogger.session, level: .warning)
return return
} }
@@ -274,15 +270,13 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
var actualNickname = peer.nickname var actualNickname = peer.nickname
// Debug logging to understand the issue // Debug logging to understand the issue
SecureLogger.log("🔍 Toggle favorite - peer.nickname: '\(peer.nickname)', peer.displayName: '\(peer.displayName)', peerID: \(peerID)", SecureLogger.debug("🔍 Toggle favorite - peer.nickname: '\(peer.nickname)', peer.displayName: '\(peer.displayName)', peerID: \(peerID)", category: .session)
category: SecureLogger.session, level: .debug)
if actualNickname.isEmpty { if actualNickname.isEmpty {
// Try to get from mesh service's current peer list // Try to get from mesh service's current peer list
if let meshPeerNickname = meshService.peerNickname(peerID: peerID) { if let meshPeerNickname = meshService.peerNickname(peerID: peerID) {
actualNickname = meshPeerNickname actualNickname = meshPeerNickname
SecureLogger.log("🔍 Got nickname from mesh service: '\(actualNickname)'", SecureLogger.debug("🔍 Got nickname from mesh service: '\(actualNickname)'", category: .session)
category: SecureLogger.session, level: .debug)
} }
} }
@@ -297,7 +291,7 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
var peerNostrKey = peer.nostrPublicKey var peerNostrKey = peer.nostrPublicKey
if peerNostrKey == nil { if peerNostrKey == nil {
// Try to get from NostrIdentityBridge association // Try to get from NostrIdentityBridge association
peerNostrKey = NostrIdentityBridge.getNostrPublicKey(for: peer.noisePublicKey) peerNostrKey = idBridge.getNostrPublicKey(for: peer.noisePublicKey)
} }
// Add favorite // Add favorite
@@ -309,11 +303,15 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
} }
// Log the final nickname being saved // Log the final nickname being saved
SecureLogger.log("⭐️ Toggled favorite for '\(finalNickname)' (peerID: \(peerID), was: \(wasFavorite), now: \(!wasFavorite))", SecureLogger.debug("⭐️ Toggled favorite for '\(finalNickname)' (peerID: \(peerID), was: \(wasFavorite), now: \(!wasFavorite))", category: .session)
category: SecureLogger.session, level: .debug)
// Send favorite notification to the peer // Send favorite notification to the peer via router (mesh or Nostr)
meshService.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite) if let router = messageRouter {
router.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)
} else {
// Fallback to mesh-only if router not yet wired
meshService.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)
}
// Force update of peers to reflect the change // Force update of peers to reflect the change
updatePeers() updatePeers()
@@ -324,39 +322,7 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
} }
} }
/// Toggle blocked status func getFingerprint(for peerID: PeerID) -> String? {
func toggleBlocked(_ peerID: String) {
guard let fingerprint = getFingerprint(for: peerID) else { return }
// Get or create social identity
var identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprint)
?? SocialIdentity(
fingerprint: fingerprint,
localPetname: nil,
claimedNickname: getPeer(by: peerID)?.displayName ?? "Unknown",
trustLevel: .unknown,
isFavorite: false,
isBlocked: false,
notes: nil
)
// Toggle blocked status
identity.isBlocked = !identity.isBlocked
// Can't be both favorite and blocked
if identity.isBlocked {
identity.isFavorite = false
// Also remove from favorites service
if let peer = getPeer(by: peerID) {
favoritesService.removeFavorite(peerNoisePublicKey: peer.noisePublicKey)
}
}
SecureIdentityStateManager.shared.updateSocialIdentity(identity)
}
/// Get fingerprint for peer ID
func getFingerprint(for peerID: String) -> String? {
// Check cache first // Check cache first
if let cached = fingerprintCache[peerID] { if let cached = fingerprintCache[peerID] {
return cached return cached
@@ -381,23 +347,13 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
// MARK: - Compatibility Methods (for easy migration) // MARK: - Compatibility Methods (for easy migration)
var allPeers: [BitchatPeer] { peers } var allPeers: [BitchatPeer] { peers }
var connectedPeers: [String] { Array(connectedPeerIDs) } var connectedPeers: Set<PeerID> { connectedPeerIDs }
var favoritePeers: Set<String> { var favoritePeers: Set<String> {
Set(favorites.compactMap { getFingerprint(for: $0.id) }) Set(favorites.compactMap { getFingerprint(for: $0.peerID) })
} }
var blockedUsers: Set<String> { var blockedUsers: Set<String> {
Set(peers.compactMap { peer in Set(peers.compactMap { peer in
isBlocked(peer.id) ? getFingerprint(for: peer.id) : nil isBlocked(peer.peerID) ? getFingerprint(for: peer.peerID) : nil
}) })
} }
} }
// MARK: - Helper Extensions
extension Data {
func sha256Fingerprint() -> String {
// Implementation matches existing fingerprint generation in NoiseEncryptionService
let hash = SHA256.hash(data: self)
return hash.map { String(format: "%02x", $0) }.joined()
}
}
+184
View File
@@ -0,0 +1,184 @@
import Foundation
/// QR verification scaffolding: schema, signing, and basic challenge/response helpers.
final class VerificationService {
static let shared = VerificationService()
// Injected Noise service from the running transport (do NOT create new BLEService)
private var noise: NoiseEncryptionService?
func configure(with noise: NoiseEncryptionService) { self.noise = noise }
/// Encapsulates the data encoded into a verification QR
struct VerificationQR: Codable {
let v: Int
let noiseKeyHex: String
let signKeyHex: String
let npub: String?
let nickname: String
let ts: Int64
let nonceB64: String
var sigHex: String
static let context = "bitchat-verify-v1"
/// Canonical bytes used for signature (deterministic ordering)
func canonicalBytes() -> Data {
var out = Data()
func appendField(_ s: String) {
let d = s.data(using: .utf8) ?? Data()
out.append(UInt8(min(d.count, 255)))
out.append(d.prefix(255))
}
appendField(Self.context)
appendField(String(v))
appendField(noiseKeyHex.lowercased())
appendField(signKeyHex.lowercased())
appendField(npub ?? "")
appendField(nickname)
appendField(String(ts))
appendField(nonceB64)
return out
}
func toURLString() -> String {
var comps = URLComponents()
comps.scheme = "bitchat"
comps.host = "verify"
comps.queryItems = [
URLQueryItem(name: "v", value: String(v)),
URLQueryItem(name: "noise", value: noiseKeyHex),
URLQueryItem(name: "sign", value: signKeyHex),
URLQueryItem(name: "nick", value: nickname),
URLQueryItem(name: "ts", value: String(ts)),
URLQueryItem(name: "nonce", value: nonceB64),
URLQueryItem(name: "sig", value: sigHex)
] + (npub != nil ? [URLQueryItem(name: "npub", value: npub)] : [])
return comps.string ?? ""
}
static func fromURL(_ url: URL) -> VerificationQR? {
guard url.scheme == "bitchat", url.host == "verify",
let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems else { return nil }
func val(_ name: String) -> String? { items.first(where: { $0.name == name })?.value }
guard let vStr = val("v"), let v = Int(vStr),
let noise = val("noise"), let sign = val("sign"),
let nick = val("nick"), let tsStr = val("ts"), let ts = Int64(tsStr),
let nonce = val("nonce"), let sig = val("sig") else { return nil }
return VerificationQR(v: v, noiseKeyHex: noise, signKeyHex: sign, npub: val("npub"), nickname: nick, ts: ts, nonceB64: nonce, sigHex: sig)
}
}
// MARK: - Public API
/// Build a signed QR string for the current identity
func buildMyQRString(nickname: String, npub: String?) -> String? {
// Simple short-lived cache to speed up sheet opening
struct Cache { static var last: (nick: String, npub: String?, builtAt: Date, value: String)? }
if let c = Cache.last, c.nick == nickname, c.npub == npub, Date().timeIntervalSince(c.builtAt) < 60 {
return c.value
}
guard let noise = noise else { return nil }
let noiseKey = noise.getStaticPublicKeyData().hexEncodedString()
let signKey = noise.getSigningPublicKeyData().hexEncodedString()
let ts = Int64(Date().timeIntervalSince1970)
var nonce = Data(count: 16)
_ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
let nonceB64 = nonce.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
let payload = VerificationQR(v: 1, noiseKeyHex: noiseKey, signKeyHex: signKey, npub: npub, nickname: nickname, ts: ts, nonceB64: nonceB64, sigHex: "")
let msg = payload.canonicalBytes()
guard let sig = noise.signData(msg) else { return nil }
let signed = VerificationQR(v: payload.v,
noiseKeyHex: payload.noiseKeyHex,
signKeyHex: payload.signKeyHex,
npub: payload.npub,
nickname: payload.nickname,
ts: payload.ts,
nonceB64: payload.nonceB64,
sigHex: sig.hexEncodedString())
let out = signed.toURLString()
Cache.last = (nickname, npub, Date(), out)
return out
}
/// Verify a scanned QR and return the parsed payload if valid (signature + freshness checks)
func verifyScannedQR(_ urlString: String, maxAge: TimeInterval = TransportConfig.verificationQRMaxAgeSeconds) -> VerificationQR? {
guard let url = URL(string: urlString), let qr = VerificationQR.fromURL(url) else { return nil }
// Freshness
let now = Date().timeIntervalSince1970
if now - Double(qr.ts) > maxAge { return nil }
// Verify signature using embedded ed25519 signKey
guard let sig = Data(hexString: qr.sigHex), let signKey = Data(hexString: qr.signKeyHex) else { return nil }
guard let noise = noise else { return nil }
let ok = noise.verifySignature(sig, for: qr.canonicalBytes(), publicKey: signKey)
return ok ? qr : nil
}
// MARK: - Noise payloads (scaffold only)
func buildVerifyChallenge(noiseKeyHex: String, nonceA: Data) -> Data {
// TLV: [0x01 len noiseKeyHex ascii] [0x02 len nonceA]
var tlv = Data()
let n0: [UInt8] = [0x01, UInt8(min(noiseKeyHex.count, 255))]
tlv.append(contentsOf: n0)
tlv.append(noiseKeyHex.data(using: .utf8)!.prefix(255))
tlv.append(0x02)
tlv.append(UInt8(min(nonceA.count, 255)))
tlv.append(nonceA.prefix(255))
return NoisePayload(type: .verifyChallenge, data: tlv).encode()
}
func buildVerifyResponse(noiseKeyHex: String, nonceA: Data) -> Data? {
// Sign context: verify-response | noiseKeyHex | nonceA
var msg = Data("bitchat-verify-resp-v1".utf8)
let nk = noiseKeyHex.data(using: .utf8) ?? Data()
msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255))
msg.append(nonceA)
guard let noise = noise, let sig = noise.signData(msg) else { return nil }
var tlv = Data()
tlv.append(0x01); tlv.append(UInt8(min(nk.count, 255))); tlv.append(nk.prefix(255))
tlv.append(0x02); tlv.append(UInt8(min(nonceA.count, 255))); tlv.append(nonceA.prefix(255))
tlv.append(0x03); tlv.append(UInt8(min(sig.count, 255))); tlv.append(sig.prefix(255))
return NoisePayload(type: .verifyResponse, data: tlv).encode()
}
func parseVerifyChallenge(_ data: Data) -> (noiseKeyHex: String, nonceA: Data)? {
var idx = 0
func take(_ n: Int) -> Data? {
guard idx + n <= data.count else { return nil }
let d = data[idx..<(idx+n)]
idx += n
return Data(d)
}
// Expect type already stripped; we receive only TLV here
// TLV 0x01 noiseKeyHex
guard let t1 = take(1), t1[0] == 0x01, let l1 = take(1), let s1 = take(Int(l1[0])),
let noiseStr = String(data: s1, encoding: .utf8) else { return nil }
// TLV 0x02 nonceA
guard let t2 = take(1), t2[0] == 0x02, let l2 = take(1), let nA = take(Int(l2[0])) else { return nil }
return (noiseStr, nA)
}
func parseVerifyResponse(_ data: Data) -> (noiseKeyHex: String, nonceA: Data, signature: Data)? {
var idx = 0
func take(_ n: Int) -> Data? {
guard idx + n <= data.count else { return nil }
let d = data[idx..<(idx+n)]
idx += n
return Data(d)
}
guard let t1 = take(1), t1[0] == 0x01, let l1 = take(1), let s1 = take(Int(l1[0])),
let noiseStr = String(data: s1, encoding: .utf8) else { return nil }
guard let t2 = take(1), t2[0] == 0x02, let l2 = take(1), let nA = take(Int(l2[0])) else { return nil }
guard let t3 = take(1), t3[0] == 0x03, let l3 = take(1), let sig = take(Int(l3[0])) else { return nil }
return (noiseStr, nA, sig)
}
func verifyResponseSignature(noiseKeyHex: String, nonceA: Data, signature: Data, signerPublicKeyHex: String) -> Bool {
var msg = Data("bitchat-verify-resp-v1".utf8)
let nk = noiseKeyHex.data(using: .utf8) ?? Data()
msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255))
msg.append(nonceA)
guard let noise = noise, let pub = Data(hexString: signerPublicKeyHex) else { return false }
return noise.verifySignature(signature, for: msg, publicKey: pub)
}
}
+237
View File
@@ -0,0 +1,237 @@
import Foundation
import CryptoKit
// Golomb-Coded Set (GCS) filter utilities for sync.
// Hashing:
// - Packet ID is 16 bytes (see PacketIdUtil). For GCS mapping, use h64 = first 8 bytes of SHA-256 over the 16-byte ID.
// - Map to [1, M) by computing (h64 % M) and remapping 0 -> 1 to avoid zero-length deltas.
// Encoding (v1):
// - Sort mapped values ascending; encode deltas (first is v0, then vi - v{i-1}) as positive integers x >= 1.
// - Golomb-Rice with parameter P: q = (x - 1) >> P encoded as unary (q ones then a zero), then write P-bit remainder r = (x - 1) & ((1<<P)-1).
// - Bitstream is MSB-first within each byte.
enum GCSFilter {
struct Params { let p: Int; let m: UInt32; let data: Data }
// Derive P from FPR (~ 1 / 2^P)
static func deriveP(targetFpr: Double) -> Int {
let f = max(0.000001, min(0.25, targetFpr))
// ceil(log2(1/f))
let p = Int(ceil(log2(1.0 / f)))
return max(1, p)
}
// Estimate max elements that fit in size bytes: bits per element ~= P + 2 (approx)
static func estimateMaxElements(sizeBytes: Int, p: Int) -> Int {
let bits = max(8, sizeBytes * 8)
let per = max(3, p + 2)
return max(1, bits / per)
}
static func buildFilter(ids: [Data], maxBytes: Int, targetFpr: Double) -> Params {
let p = deriveP(targetFpr: targetFpr)
guard !ids.isEmpty else {
return Params(p: p, m: 1, data: Data())
}
let cap = estimateMaxElements(sizeBytes: maxBytes, p: p)
let selected = Array(ids.prefix(cap))
let range = max(1, hashRange(count: selected.count, p: p))
let modulo = UInt64(range)
var mapped = selected
.map { h64($0) }
.map { mapHash($0, modulo: modulo) }
.sorted()
mapped = normalizeMappedValues(mapped, modulo: modulo)
if mapped.isEmpty {
return Params(p: p, m: range, data: Data())
}
var encoded = encode(sorted: mapped, p: p)
var trimmedCount = mapped.count
while encoded.count > maxBytes && trimmedCount > 0 {
if trimmedCount == 1 {
mapped.removeAll()
encoded = Data()
break
}
trimmedCount = max(1, (trimmedCount * 9) / 10)
mapped = Array(mapped.prefix(trimmedCount))
encoded = encode(sorted: mapped, p: p)
}
return Params(p: p, m: range, data: encoded)
}
static func decodeToSortedSet(p: Int, m: UInt32, data: Data) -> [UInt64] {
var values: [UInt64] = []
let reader = BitReader(data)
var acc: UInt64 = 0
while true {
guard let q = reader.readUnary() else { break }
guard let r = reader.readBits(count: p) else { break }
let x = (UInt64(q) << UInt64(p)) + UInt64(r) + 1
acc &+= x
if acc >= UInt64(m) { break }
values.append(acc)
}
return values
}
static func contains(sortedValues: [UInt64], candidate: UInt64) -> Bool {
var lo = 0
var hi = sortedValues.count - 1
while lo <= hi {
let mid = (lo + hi) >> 1
let v = sortedValues[mid]
if v == candidate { return true }
if v < candidate { lo = mid + 1 } else { hi = mid - 1 }
}
return false
}
static func bucket(for id: Data, modulus m: UInt32) -> UInt64 {
let modulo = UInt64(max(1, m))
guard modulo > 1 else { return 0 }
return mapHash(h64(id), modulo: modulo)
}
private static func h64(_ id16: Data) -> UInt64 {
var hasher = SHA256()
hasher.update(data: id16)
let d = hasher.finalize()
let db = Data(d)
var x: UInt64 = 0
let take = min(8, db.count)
for i in 0..<take { x = (x << 8) | UInt64(db[i]) }
return x & 0x7fff_ffff_ffff_ffff
}
private static func hashRange(count: Int, p: Int) -> UInt32 {
guard count > 0 else { return 1 }
if p >= 64 { return UInt32.max }
let multiplier = UInt64(1) << UInt64(p)
let (product, overflow) = UInt64(count).multipliedReportingOverflow(by: multiplier)
if overflow { return UInt32.max }
if product == 0 { return 1 }
return product > UInt64(UInt32.max) ? UInt32.max : UInt32(product)
}
private static func mapHash(_ hash: UInt64, modulo: UInt64) -> UInt64 {
guard modulo > 1 else { return 0 }
let value = hash % modulo
if value == 0 { return 1 }
return value
}
private static func normalizeMappedValues(_ values: [UInt64], modulo: UInt64) -> [UInt64] {
guard modulo > 1 else { return [] }
guard !values.isEmpty else { return [] }
var result: [UInt64] = []
result.reserveCapacity(values.count)
var last: UInt64 = 0
for value in values {
let normalized = min(value, modulo - 1)
if normalized > last {
result.append(normalized)
last = normalized
}
}
return result
}
private static func encode(sorted: [UInt64], p: Int) -> Data {
let writer = BitWriter()
var prev: UInt64 = 0
let mask: UInt64 = (p >= 64) ? ~0 : ((1 << UInt64(p)) - 1)
for v in sorted {
let delta = v &- prev
prev = v
let x = delta
let q = (x &- 1) >> UInt64(p)
let r = (x &- 1) & mask
// unary q ones then zero
if q > 0 { writer.writeOnes(count: Int(q)) }
writer.writeBit(0)
writer.writeBits(value: r, count: p)
}
return writer.toData()
}
// MARK: - Bit helpers (MSB-first)
private final class BitWriter {
private var buf = Data()
private var cur: UInt8 = 0
private var nbits: Int = 0
func writeBit(_ bit: Int) { // 0 or 1
cur = UInt8((Int(cur) << 1) | (bit & 1))
nbits += 1
if nbits == 8 {
buf.append(cur)
cur = 0; nbits = 0
}
}
func writeOnes(count: Int) {
guard count > 0 else { return }
for _ in 0..<count { writeBit(1) }
}
func writeBits(value: UInt64, count: Int) {
guard count > 0 else { return }
for i in stride(from: count - 1, through: 0, by: -1) {
let bit = Int((value >> UInt64(i)) & 1)
writeBit(bit)
}
}
func toData() -> Data {
if nbits > 0 {
let rem = UInt8(Int(cur) << (8 - nbits))
buf.append(rem)
cur = 0; nbits = 0
}
return buf
}
}
private final class BitReader {
private let data: Data
private var idx: Int = 0
private var cur: UInt8 = 0
private var left: Int = 0
init(_ data: Data) {
self.data = data
if !data.isEmpty {
cur = data[0]
left = 8
}
}
func readBit() -> Int? {
if idx >= data.count { return nil }
let bit = (Int(cur) >> 7) & 1
cur = UInt8((Int(cur) << 1) & 0xFF)
left -= 1
if left == 0 {
idx += 1
if idx < data.count { cur = data[idx]; left = 8 }
}
return bit
}
func readUnary() -> Int? {
var q = 0
while true {
guard let b = readBit() else { return nil }
if b == 1 { q += 1 } else { break }
}
return q
}
func readBits(count: Int) -> UInt64? {
var v: UInt64 = 0
for _ in 0..<count {
guard let b = readBit() else { return nil }
v = (v << 1) | UInt64(b)
}
return v
}
}
}
+330
View File
@@ -0,0 +1,330 @@
import Foundation
// Gossip-based sync manager using on-demand GCS filters
final class GossipSyncManager {
protocol Delegate: AnyObject {
func sendPacket(_ packet: BitchatPacket)
func sendPacket(to peerID: PeerID, packet: BitchatPacket)
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket
}
struct Config {
var seenCapacity: Int = 1000 // max packets per sync (cap across types)
var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
var gcsTargetFpr: Double = 0.01 // 1%
var maxMessageAgeSeconds: TimeInterval = 900 // 15 min - discard older messages
var maintenanceIntervalSeconds: TimeInterval = 30.0
var stalePeerCleanupIntervalSeconds: TimeInterval = 60.0
var stalePeerTimeoutSeconds: TimeInterval = 60.0
}
private let myPeerID: PeerID
private let config: Config
weak var delegate: Delegate?
// Storage: broadcast messages (ordered by insert), and latest announce per sender
private var messages: [String: BitchatPacket] = [:] // idHex -> packet
private var messageOrder: [String] = []
private var latestAnnouncementByPeer: [String: (id: String, packet: BitchatPacket)] = [:]
// Timer
private var periodicTimer: DispatchSourceTimer?
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
private var lastStalePeerCleanup: Date = .distantPast
init(myPeerID: PeerID, config: Config = Config()) {
self.myPeerID = myPeerID
self.config = config
}
func start() {
stop()
let timer = DispatchSource.makeTimerSource(queue: queue)
let interval = max(0.1, config.maintenanceIntervalSeconds)
timer.schedule(deadline: .now() + interval, repeating: interval, leeway: .seconds(1))
timer.setEventHandler { [weak self] in
self?.performPeriodicMaintenance()
}
timer.resume()
periodicTimer = timer
}
func stop() {
periodicTimer?.cancel(); periodicTimer = nil
}
func scheduleInitialSyncToPeer(_ peerID: PeerID, delaySeconds: TimeInterval = 5.0) {
queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
self?.sendRequestSync(to: peerID)
}
}
func onPublicPacketSeen(_ packet: BitchatPacket) {
queue.async { [weak self] in
self?._onPublicPacketSeen(packet)
}
}
// Helper to check if a packet is within the age threshold
private func isPacketFresh(_ packet: BitchatPacket) -> Bool {
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
let ageThresholdMs = UInt64(config.maxMessageAgeSeconds * 1000)
// If current time is less than threshold, accept all (handle clock issues gracefully)
guard nowMs >= ageThresholdMs else { return true }
let cutoffMs = nowMs - ageThresholdMs
return packet.timestamp >= cutoffMs
}
private func isAnnouncementFresh(_ packet: BitchatPacket) -> Bool {
guard config.stalePeerTimeoutSeconds > 0 else { return true }
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
let timeoutMs = UInt64(config.stalePeerTimeoutSeconds * 1000)
guard nowMs >= timeoutMs else { return true }
let cutoffMs = nowMs - timeoutMs
return packet.timestamp >= cutoffMs
}
private func _onPublicPacketSeen(_ packet: BitchatPacket) {
let mt = MessageType(rawValue: packet.type)
let isBroadcastRecipient: Bool = {
guard let r = packet.recipientID else { return true }
return r.count == 8 && r.allSatisfy { $0 == 0xFF }
}()
let isBroadcastMessage = (mt == .message && isBroadcastRecipient)
let isAnnounce = (mt == .announce)
guard isBroadcastMessage || isAnnounce else { return }
// Reject expired packets to prevent ghost peers and old messages
guard isPacketFresh(packet) else { return }
if isAnnounce {
guard isAnnouncementFresh(packet) else {
let sender = packet.senderID.hexEncodedString().lowercased()
removeState(forNormalizedPeerID: sender)
return
}
}
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
if isBroadcastMessage {
if messages[idHex] == nil {
messages[idHex] = packet
messageOrder.append(idHex)
// Enforce capacity
let cap = max(1, config.seenCapacity)
while messageOrder.count > cap {
let victim = messageOrder.removeFirst()
messages.removeValue(forKey: victim)
}
}
} else if isAnnounce {
let sender = packet.senderID.hexEncodedString().lowercased()
latestAnnouncementByPeer[sender] = (id: idHex, packet: packet)
}
}
private func sendRequestSync() {
let payload = buildGcsPayload()
let pkt = BitchatPacket(
type: MessageType.requestSync.rawValue,
senderID: Data(hexString: myPeerID.id) ?? Data(),
recipientID: nil, // broadcast
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: 0 // local-only
)
let signed = delegate?.signPacketForBroadcast(pkt) ?? pkt
delegate?.sendPacket(signed)
}
private func sendRequestSync(to peerID: PeerID) {
let payload = buildGcsPayload()
var recipient = Data()
var temp = peerID.id
while temp.count >= 2 && recipient.count < 8 {
let hexByte = String(temp.prefix(2))
if let b = UInt8(hexByte, radix: 16) { recipient.append(b) }
temp = String(temp.dropFirst(2))
}
let pkt = BitchatPacket(
type: MessageType.requestSync.rawValue,
senderID: Data(hexString: myPeerID.id) ?? Data(),
recipientID: recipient,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: 0 // local-only
)
let signed = delegate?.signPacketForBroadcast(pkt) ?? pkt
delegate?.sendPacket(to: peerID, packet: signed)
}
func handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) {
queue.async { [weak self] in
self?._handleRequestSync(from: peerID, request: request)
}
}
private func _handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) {
// Decode GCS into sorted set and prepare membership checker
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
func mightContain(_ id: Data) -> Bool {
let bucket = GCSFilter.bucket(for: id, modulus: request.m)
return GCSFilter.contains(sortedValues: sorted, candidate: bucket)
}
// 1) Announcements: send latest per peer if requester lacks them (and not expired)
for (_, pair) in latestAnnouncementByPeer {
let (idHex, pkt) = pair
guard isPacketFresh(pkt) else { continue }
let idBytes = Data(hexString: idHex) ?? Data()
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
delegate?.sendPacket(to: peerID, packet: toSend)
}
}
// 2) Broadcast messages: send all missing (and not expired)
let toSendMsgs = messageOrder.compactMap { messages[$0] }
for pkt in toSendMsgs {
guard isPacketFresh(pkt) else { continue }
let idBytes = PacketIdUtil.computeId(pkt)
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
delegate?.sendPacket(to: peerID, packet: toSend)
}
}
}
// Build REQUEST_SYNC payload using current candidates and GCS params
private func buildGcsPayload() -> Data {
// Collect candidates: latest announce per peer + broadcast messages (only fresh)
var candidates: [BitchatPacket] = []
candidates.reserveCapacity(latestAnnouncementByPeer.count + messageOrder.count)
for (_, pair) in latestAnnouncementByPeer {
if isPacketFresh(pair.packet) {
candidates.append(pair.packet)
}
}
for id in messageOrder {
if let p = messages[id], isPacketFresh(p) {
candidates.append(p)
}
}
// Sort by timestamp desc
candidates.sort { $0.timestamp > $1.timestamp }
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
let nMax = GCSFilter.estimateMaxElements(sizeBytes: config.gcsMaxBytes, p: p)
let cap = max(1, config.seenCapacity)
let takeN = min(candidates.count, min(nMax, cap))
if takeN <= 0 {
let req = RequestSyncPacket(p: p, m: 1, data: Data())
return req.encode()
}
let ids: [Data] = candidates.prefix(takeN).map { PacketIdUtil.computeId($0) }
let params = GCSFilter.buildFilter(ids: ids, maxBytes: config.gcsMaxBytes, targetFpr: config.gcsTargetFpr)
let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data)
return req.encode()
}
// Periodic cleanup of expired messages and announcements
private func cleanupExpiredMessages() {
// Remove expired announcements
latestAnnouncementByPeer = latestAnnouncementByPeer.filter { _, pair in
isPacketFresh(pair.packet)
}
// Remove expired messages
let expiredMessageIds = messages.compactMap { id, pkt in
isPacketFresh(pkt) ? nil : id
}
for id in expiredMessageIds {
messages.removeValue(forKey: id)
messageOrder.removeAll { $0 == id }
}
}
private func performPeriodicMaintenance(now: Date = Date()) {
cleanupExpiredMessages()
cleanupStaleAnnouncementsIfNeeded(now: now)
sendRequestSync()
}
private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
guard now.timeIntervalSince(lastStalePeerCleanup) >= config.stalePeerCleanupIntervalSeconds else {
return
}
lastStalePeerCleanup = now
cleanupStaleAnnouncements(now: now)
}
private func cleanupStaleAnnouncements(now: Date) {
let timeoutMs = UInt64(config.stalePeerTimeoutSeconds * 1000)
let nowMs = UInt64(now.timeIntervalSince1970 * 1000)
guard nowMs >= timeoutMs else { return }
let cutoff = nowMs - timeoutMs
let stalePeerIDs = latestAnnouncementByPeer.compactMap { (peerHex, pair) -> String? in
pair.packet.timestamp < cutoff ? peerHex.lowercased() : nil
}
guard !stalePeerIDs.isEmpty else { return }
for peerKey in stalePeerIDs {
removeState(forNormalizedPeerID: peerKey)
}
}
// Explicit removal hook for LEAVE/stale peer
func removeAnnouncementForPeer(_ peerID: PeerID) {
queue.async { [weak self] in
self?._removeAnnouncementForPeer(peerID)
}
}
private func _removeAnnouncementForPeer(_ peerID: PeerID) {
let normalizedPeerID = peerID.id.lowercased()
removeState(forNormalizedPeerID: normalizedPeerID)
}
private func removeState(forNormalizedPeerID normalizedPeerID: String) {
_ = latestAnnouncementByPeer.removeValue(forKey: normalizedPeerID)
// Remove messages from this peer
// Collect IDs to remove first to avoid concurrent modification
let messageIdsToRemove = messages.compactMap { (id, message) -> String? in
message.senderID.hexEncodedString().lowercased() == normalizedPeerID ? id : nil
}
// Remove messages and update messageOrder
for id in messageIdsToRemove {
messages.removeValue(forKey: id)
messageOrder.removeAll { $0 == id }
}
}
}
#if DEBUG
extension GossipSyncManager {
func _performMaintenanceSynchronously(now: Date = Date()) {
queue.sync {
performPeriodicMaintenance(now: now)
}
}
func _hasAnnouncement(for peerID: PeerID) -> Bool {
queue.sync {
latestAnnouncementByPeer[peerID.id.lowercased()] != nil
}
}
func _messageCount(for peerID: PeerID) -> Int {
queue.sync {
messages.values.filter { $0.senderID.hexEncodedString().lowercased() == peerID.id.lowercased() }.count
}
}
}
#endif
+17
View File
@@ -0,0 +1,17 @@
import Foundation
import CryptoKit
// Deterministic packet ID used for gossip sync membership
// ID = first 16 bytes of SHA-256 over: [type | senderID | timestamp | payload]
enum PacketIdUtil {
static func computeId(_ packet: BitchatPacket) -> Data {
var hasher = SHA256()
hasher.update(data: Data([packet.type]))
hasher.update(data: packet.senderID)
var tsBE = packet.timestamp.bigEndian
withUnsafeBytes(of: &tsBE) { raw in hasher.update(data: Data(raw)) }
hasher.update(data: packet.payload)
let digest = hasher.finalize()
return Data(digest.prefix(16))
}
}
+37
View File
@@ -0,0 +1,37 @@
//
// Color+Peer.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import SwiftUI
extension Color {
private static var peerColorCache: [String: Color] = [:]
init(peerSeed: String, isDark: Bool) {
let cacheKey = peerSeed + (isDark ? "|dark" : "|light")
if let cached = Self.peerColorCache[cacheKey] {
self = cached
}
let h = peerSeed.djb2()
var hue = Double(h % 1000) / 1000.0
let orange = 30.0 / 360.0
if abs(hue - orange) < TransportConfig.uiColorHueAvoidanceDelta {
hue = fmod(hue + TransportConfig.uiColorHueOffset, 1.0)
}
let sRand = Double((h >> 17) & 0x3FF) / 1023.0
let bRand = Double((h >> 27) & 0x3FF) / 1023.0
let sBase: Double = isDark ? 0.80 : 0.70
let sRange: Double = 0.20
let bBase: Double = isDark ? 0.75 : 0.45
let bRange: Double = isDark ? 0.16 : 0.14
let saturation = min(1.0, max(0.50, sBase + (sRand - 0.5) * sRange))
let brightness = min(1.0, max(0.35, bBase + (bRand - 0.5) * bRange))
let c = Color(hue: hue, saturation: saturation, brightness: brightness)
Self.peerColorCache[cacheKey] = c
self = c
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ import Compression
struct CompressionUtil { struct CompressionUtil {
// Compression threshold - don't compress if data is smaller than this // Compression threshold - don't compress if data is smaller than this
static let compressionThreshold = 100 // bytes static let compressionThreshold = TransportConfig.compressionThresholdBytes // bytes
// Compress data using zlib algorithm (most compatible) // Compress data using zlib algorithm (most compatible)
static func compress(_ data: Data) -> Data? { static func compress(_ data: Data) -> Data? {
+22
View File
@@ -0,0 +1,22 @@
//
// Data+SHA256.swift
// bitchat
//
// Created by Islam on 26/09/2025.
//
import struct Foundation.Data
import struct CryptoKit.SHA256
extension Data {
/// Returns the hex representation of SHA256 hash
func sha256Fingerprint() -> String {
// Implementation matches existing fingerprint generation in NoiseEncryptionService
sha256Hash().hexEncodedString()
}
/// Returns the SHA256 hash wrapped in Data
func sha256Hash() -> Data {
Data(SHA256.hash(data: self))
}
}
+41
View File
@@ -0,0 +1,41 @@
import SwiftUI
/// Provides Dynamic Type aware font helpers that map existing fixed sizes onto
/// preferred text styles so the UI scales with user accessibility settings.
extension Font {
static func bitchatSystem(size: CGFloat, weight: Font.Weight = .regular, design: Font.Design = .default) -> Font {
let style = Font.TextStyle.bitchatPreferredStyle(for: size)
var font = Font.system(style, design: design)
if weight != .regular {
font = font.weight(weight)
}
return font
}
}
private extension Font.TextStyle {
static func bitchatPreferredStyle(for size: CGFloat) -> Font.TextStyle {
switch size {
case ..<11.5:
return .caption2
case ..<13.0:
return .caption
case ..<13.75:
return .footnote
case ..<15.5:
return .subheadline
case ..<17.5:
return .callout
case ..<19.5:
return .body
case ..<22.5:
return .title3
case ..<27.5:
return .title2
case ..<34.0:
return .title
default:
return .largeTitle
}
}
}

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