Compare commits

..
Author SHA1 Message Date
jack 41b7f5171e Fix Cashu long-message guard bypass 2026-06-02 22:19:09 +02:00
3eb4f2bd72 Extract BLE and chat architecture policies (#1324)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2026-06-02 09:01:59 +02:00
193cfdc06a [codex] Extract BLE service policy helpers (#1321)
* Extract BLE service policy helpers

* Stabilize image media transfer test

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2026-06-01 14:16:44 +02:00
ffa0d7aa4f [codex] Extract BLE link state store (#1310)
* Refactor BLE transport event handling

* Make image output paths unique

* Keep queued Nostr read receipts alive

* Refine BLE ingress fanout

* Rediscover BLE service after invalidation

* Extract BLE notification retry buffer

* Extract BLE inbound write buffer

* Extract BLE fragment assembly buffer

* Tidy secure log handling from device run

* Extract BLE outbound fragment scheduler

* Harden app CI media tests

* Redact BLE message content from logs

* Extract BLE Noise session queues

* Fix BLE read receipt UI updates

* Allow self-authored RSR ingress replies

* Harden read receipt queue test timing

* Extract BLE outbound policy and incoming file storage

* Avoid duplicate BLE link snapshots during send

* Canonicalize Nostr relay URLs

* Extract BLE link state store

* Extract BLE connection scheduler

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2026-05-31 14:24:09 +02:00
9e84f5e822 [codex] Refactor BLE outbound scheduling and Noise queues (#1306)
* Refactor BLE transport event handling

* Make image output paths unique

* Keep queued Nostr read receipts alive

* Refine BLE ingress fanout

* Rediscover BLE service after invalidation

* Extract BLE notification retry buffer

* Extract BLE inbound write buffer

* Extract BLE fragment assembly buffer

* Tidy secure log handling from device run

* Extract BLE outbound fragment scheduler

* Harden app CI media tests

* Redact BLE message content from logs

* Extract BLE Noise session queues

* Fix BLE read receipt UI updates

* Allow self-authored RSR ingress replies

* Harden read receipt queue test timing

* Extract BLE outbound policy and incoming file storage

* Avoid duplicate BLE link snapshots during send

* Canonicalize Nostr relay URLs

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2026-05-31 14:16:11 +02:00
df36b19afe [codex] Refine BLE ingress fanout (#1280)
* Refactor BLE transport event handling

* Make image output paths unique

* Keep queued Nostr read receipts alive

* Refine BLE ingress fanout

* Rediscover BLE service after invalidation

* Extract BLE notification retry buffer

* Extract BLE inbound write buffer

* Extract BLE fragment assembly buffer

* Tidy secure log handling from device run

* Allow self-authored RSR ingress replies

* Harden read receipt queue test timing

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2026-05-31 14:08:30 +02:00
ab0da61533 [codex] Refactor BLE transport event handling (#1266)
* Refactor BLE transport event handling

* Make image output paths unique

* Keep queued Nostr read receipts alive

* Allow self-authored RSR ingress replies

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2026-05-31 13:58:27 +02:00
GitHub Action 3be8fbf1c4 Automated update of relay data - Sun May 31 07:17:57 UTC 2026 2026-05-31 07:17:57 +00:00
764f016d17 [codex] Refactor app runtime and ownership architecture (#1104)
* Refactor app runtime and view model architecture

* Move app ownership into stores and coordinators

* Fix smoke test environment injection

* Stabilize fragmentation package tests

* Fix coordinator build warnings

* Clean up chat view model warnings

* Fix Nostr relay startup coalescing

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2026-05-30 14:13:26 +02:00
GitHub Action a6cb8872fb Automated update of relay data - Sun May 24 07:05:51 UTC 2026 2026-05-24 07:05:51 +00:00
GitHub Action 3daf02732a Automated update of relay data - Sun May 17 06:57:28 UTC 2026 2026-05-17 06:57:28 +00:00
GitHub Action 0d1cdc7644 Automated update of relay data - Sun May 10 06:54:20 UTC 2026 2026-05-10 06:54:21 +00:00
GitHub Action 43eef0d49c Automated update of relay data - Sun May 3 06:51:02 UTC 2026 2026-05-03 06:51:02 +00:00
GitHub Action bafa461f26 Automated update of relay data - Sun Apr 26 06:37:54 UTC 2026 2026-04-26 06:37:54 +00:00
GitHub Action 602d2316ef Automated update of relay data - Sun Apr 19 06:34:27 UTC 2026 2026-04-19 06:34:27 +00:00
IslamandGitHub c60eff2c11 Move additional files/tests to BitFoundation (#1102) 2026-04-15 12:26:48 -05:00
IslamandGitHub 4cfcefcda6 BitFoundation module to centralize shared components (#1089)
* Run local packages’ tests as well on CI

* BitFoundation module to centralize shared components
2026-04-14 14:10:03 -05:00
GitHub Action 3e137d784c Automated update of relay data - Sun Apr 12 06:32:24 UTC 2026 2026-04-12 06:32:24 +00:00
islam 452e9e74fd Run CI build on all PRs (not just to the main) 2026-04-10 22:31:49 +01:00
jack 3afd74ffc8 Ignore .claude directory 2026-04-05 19:04:18 -05:00
951e056a55 Extract message list into dedicated MessageListView (#1080)
* Extract message list into dedicated MessageListView

* Fix broken smoke test

Mounts two distinct fixtures to cover separate render branches: a truncatable message (>2000 chars, no payment tokens) and a payment message (lightning + cashu, private, partial delivery). Expectations guard that each fixture actually reaches its intended branch.

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2026-04-05 19:01:57 -05:00
GitHub Action d265cfc765 Automated update of relay data - Sun Apr 5 06:26:16 UTC 2026 2026-04-05 06:26:16 +00:00
IslamandGitHub 473f5c7cce String trimming in a centralized manner (#1079)
* String trimming in a centralized manner

* Fix empty-nickname case
2026-04-04 15:56:39 -05:00
b5834cfc76 Cache CI build artifacts (#1085)
Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2026-04-02 20:37:20 -05:00
IslamandGitHub 097d916da7 Refactor and Extract Media-related code (#1077)
* Extract media-related code from ContentView

* Refactor hard-coded prefix and directory names
2026-04-02 20:31:51 -05:00
IslamandGitHub 4b000b0785 Refactor & Extract image viewers (#1074)
* Extract `ImagePreviewView` + add `#Preview`

* Extract image pickers + dedupe and bg processing of images

* Include a dummy photo in the preview assets vs live url

* Fix development assets + url percent encoding

* Fix iOS vs macOS build issue

* Fix SPM-related issues
2026-04-02 18:49:17 -05:00
19437a0bc6 fix: pass environmentObject to people sheet to prevent PM reopen crash (#1069)
Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2026-04-02 11:37:08 -05:00
34bae4a89d Refactor Voice Messages (#1075)
* Extract voice-related code to a dedicated VM

* Minor optimization of permission request + fix ui bug

When isPreparing is being set to true before permission is granted, the UI is shown for a fraction of a second and it’s even worse if the permission is being asked constantly if the user drags the finger even when the alert is shown

* Remove dead code

* Remove unnecessary delegate conformance

* `actor VoiceRecorder` + other minor improvements

* Centralized opening system settings + open for mic access

* Better voice-recording state handling with Enum

* Remove manual timer management

* Simplify formatting + avoid jumping UI w/ countdown

* Add `requestingPermission` state to avoid repetitive calls

* Improve guarding of the states after awaits

* Fix potential “actor reentrancy across awaits” issue

* Remove short-circuiting on .idle + guard before error

* Fix playbackLabel’s formatting for duration vs remainder

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2026-04-02 11:26:13 -05:00
0331871980 fix: missing file broadcast and leave message signatures + allow local development (#1078)
* add signatures to file transfers and LEAVE messages

* chore: setup project for local development and signing

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2026-03-31 14:12:09 -05:00
islam f9f6ac92b8 Remove dead code 2026-03-30 10:12:30 +01:00
GitHub Action ca374fd823 Automated update of relay data - Sun Mar 29 06:24:11 UTC 2026 2026-03-29 06:24:11 +00:00
GitHub Action 96f2986d65 Automated update of relay data - Sun Mar 22 06:16:48 UTC 2026 2026-03-22 06:16:48 +00:00
GitHub Action b6e45e3228 Automated update of relay data - Sun Mar 15 06:20:36 UTC 2026 2026-03-15 06:20:36 +00:00
c88ab3dd05 Move GeoRelay fetch work off the main actor (#1060)
* Run GeoRelay fetch pipeline off main actor

* Capture GeoRelay session before detached fetch

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2026-03-12 20:24:47 -10:00
7d83310bc2 Expand Nostr and identity coverage (#1059)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2026-03-12 19:41:05 -10:00
264a95b61a Raise protocol coverage to 99 percent (#1058)
* Expand protocol coverage with edge-case tests

* Stabilize read receipt transport test

* Stabilize BLE duplicate packet test

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2026-03-12 17:55:41 -10:00
8562a76367 Raise Noise coverage to 99 percent (#1057)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2026-03-12 17:16:09 -10:00
7e86d2061f Expand coverage for transport, chat, and media flows (#1056)
* Expand coverage for transport, chat, and media flows

* Stabilize transport and media coverage tests

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2026-03-12 16:20:19 -10:00
a136b5b7e9 Expand coverage for relay, identity, and location flows (#1055)
* Expand coverage for relay, identity, and location flows

* Fix macOS SwiftPM CI failures

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2026-03-12 12:50:46 -10:00
c043cf6354 Fix BLEService test local echo timing (#1054)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2026-03-12 09:53:58 -10:00
72093f9648 fix: disable macOS focus ring on chat input TextField (#905) (#1045)
Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2026-03-12 08:56:14 -10:00
187a5c3195 fix: allow verification sheet in private chat via sidebar navigation (#988) (#1046)
Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2026-03-12 08:44:43 -10:00
GitHub Action 02b2a006c5 Automated update of relay data - Sun Mar 8 06:12:28 UTC 2026 2026-03-08 06:12:28 +00:00
GitHub Action 2403a6eb90 Automated update of relay data - Sun Mar 1 06:14:25 UTC 2026 2026-03-01 06:14:25 +00:00
GitHub Action e47c5ae7b3 Automated update of relay data - Sun Feb 22 06:15:08 UTC 2026 2026-02-22 06:15:08 +00:00
GitHub Action c1f5868d2d Automated update of relay data - Sun Feb 15 06:17:47 UTC 2026 2026-02-15 06:17:47 +00:00
GitHub Action 0515f4c6e8 Automated update of relay data - Sun Feb 8 06:17:26 UTC 2026 2026-02-08 06:17:26 +00:00
8c7e3e7b9b Harden Nostr validation and BLE announce tests (#1012)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2026-02-02 18:07:19 -10:00
90a5e8ba9d fix: Rate limit iOS peer notifications to prevent flood (#972)
* fix: Rate limit iOS peer notifications to prevent flood

- Remove aggressive formIntersection that cleared recentlySeenPeers
  when peers temporarily dropped, causing them to be treated as "new"
- Add 5-minute cooldown between notifications (aligns with Android)
- Use fixed notification identifier so iOS updates existing notification
  instead of creating new ones
- Only mark peers as seen when notification is sent, so peers arriving
  during cooldown are included in the next notification

Fixes notification spam every 10-30 seconds when peers fluctuate.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: Bump version to 1.5.1

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 09:51:26 -10:00
GitHub Action 6206184862 Automated update of relay data - Sun Feb 1 06:16:49 UTC 2026 2026-02-01 06:16:49 +00:00
74cf0d89cc Fix iOS BLE mesh authentication issues in BLEService (#998)
* Fix iOS BLE mesh authentication bypass chain in BLEService

- Bind sender IDs to BLE connection UUIDs for peripherals and centrals to prevent spoofing
- Enforce explicit RSR request/response validation and remove legacy TTL==0 RSR path
- Remove TTL==0 unconditional acceptance for messages and file transfers
- Ensure gossip sync caching only occurs after a packet is accepted
- Preserve self‑sync TTL==0 dedup exception without weakening authentication

* fix: toctou in boundPeerID identified by codex

* fix: Remove unused variable and bump version to 1.5.1

- Remove unused messageType variable (compiler warning fix)
- Bump marketing version to 1.5.1

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 12:07:24 -10:00
GitHub Action b4b6aa5ca6 Automated update of relay data - Sun Jan 25 06:05:14 UTC 2026 2026-01-25 06:05:14 +00:00
GitHub Action 1e5a52f39f Automated update of relay data - Sun Jan 18 06:05:08 UTC 2026 2026-01-18 06:05:08 +00:00
3fc64f6168 feat: Implement Request Sync Manager (V2 Sync) (#965)
* feat: Implement Request Sync Manager (V2 Sync)

- Add RequestSyncManager to track and attribute sync requests
- Update BitchatPacket and BinaryProtocol to support IS_RSR flag (0x10)
- Update RequestSyncPacket with new TLV fields (sinceTimestamp, fragmentIdFilter)
- Update GossipSyncManager to use unicast sync requests and mark responses as RSR
- Update BLEService to enforce timestamp validation for normal packets and exempt valid RSRs
- Add documentation for the new sync manager mechanism

* fix: Resolve compilation errors in V2 Sync implementation

- Remove duplicate restartGossipManager in BLEService
- Add missing TransportConfig constants for sync
- Add 'sync' log category to BitLogger
- Add missing BitLogger import in GossipSyncManager

* fix: Update tests for V2 Sync changes

- Add requestSyncManager parameter to GossipSyncManager init in tests
- Implement getConnectedPeers stub in RecordingDelegate
- Remove unused variable warning in SubscriptionRateLimitTests

---------

Co-authored-by: a1denvalu3 <>
Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2026-01-17 07:43:02 -10:00
9964710de2 Improve BLE mesh reliability for large transfers (#964)
* Improve BLE mesh reliability for large transfers

Major reliability improvements for fragment-based transfers (photos, files):

**Notification Queue Fixes**
- Fix silent packet loss when notification queue is full - now queues for retry
- Fix retry queue bug where remaining items were lost when one retry failed
- Add periodic drain mechanism as backup (every 5 seconds)

**Write Queue Fixes**
- Fix drainPendingWrites to use atomic take-send-requeue pattern
- Add logging when peripheral is ready for more writes
- Add periodic drain for pending writes as backup

**Fragment Pacing**
- Increase fragment spacing from 4-5ms to 25-30ms to prevent buffer overflow
- Conservative pacing prevents packet loss on congested BLE connections

**Thread Safety**
- Fix race conditions in stopServices() and emergencyDisconnectAll()
- Synchronize access to peripherals/centrals dictionaries during cleanup
- Clear pending message queues in emergencyDisconnectAll()

**Error Recovery**
- Add handlers for all BLE state transitions (poweredOff, unauthorized, etc.)
- Clear Noise session and re-initiate handshake on decryption failure
- Queue ACKs/receipts for delivery after handshake instead of dropping
- Re-queue failed pending messages for retry

**Other Improvements**
- Add route freshness validation in MeshTopologyTracker (60s threshold)
- Add TTL (24h) and size limits (100 per peer) for MessageRouter outbox
- Fix connection timeout to check peripheral.state before canceling
- Add NoiseEncryptionService.clearSession(for:) method

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix race condition and increase test timeouts

- Fix race in sendNoisePayload: use sync barrier instead of async
  to ensure payload is queued before initiating handshake
  (addresses Codex review feedback)

- Increase BLEServiceTests sleep from 0.5s to 1.0s for CI reliability

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 07:39:31 -10:00
806c420313 Add comprehensive test coverage for ChatViewModel and BLEService (#962)
* Add comprehensive test coverage for ChatViewModel and BLEService

This commit adds 14 new tests to improve the safety net for future
refactoring of ChatViewModel and BLEService:

New test files:
- ChatViewModelTorTests: Tor lifecycle notification handlers (8 tests)
- ChatViewModelDeliveryStatusTests: Delivery status state machine (6 tests)
- ChatViewModelRefactoringTests: Command routing and message handling (4 tests)
- BLEServiceCoreTests: Packet deduplication and stale broadcast filtering (2 tests)
- PublicMessagePipelineTests: Message ordering and deduplication (4 tests)
- MessageRouterTests: Transport selection and outbox behavior (4 tests)
- PrivateChatManagerTests: Chat selection and read receipts (2 tests)
- UnifiedPeerServiceTests: Fingerprint resolution and blocking (2 tests)
- RelayControllerTests: TTL, handshake, and fragment relay logic (4 tests)

Modified files:
- MockTransport: Added peer snapshot publishing on connect/disconnect
- TestHelpers: Added waitUntil polling helper for async tests
- MockIdentityManager: Extended for new test scenarios

Total: 454 tests across 64 suites (was 440)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix flaky test by using waitUntil instead of fixed sleep

Replace fixed 200ms sleep with waitUntil helper for more reliable
async assertion in routing tests. This prevents timing issues on CI.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 16:02:13 -10:00
jackandGitHub 194dedac43 Merge pull request #961 from permissionlesstech/chore/cleanup-dead-code
Remove dead code and stabilize tests
2026-01-16 08:42:10 -10:00
jack 9af46a9ff8 Normalize Cashu chip URLs 2026-01-15 09:57:40 -10:00
jack da3fcd5a21 Remove dead code and stabilize tests 2026-01-15 09:46:35 -10:00
jackandGitHub b282536080 Merge pull request #960 from permissionlesstech/cleanup/dead-code-and-helpers
Remove dead code and extract helper methods
2026-01-15 07:18:06 -10:00
jackandGitHub e156356c71 Update README.md 2026-01-14 14:39:39 -10:00
jackandClaude Opus 4.5 81a6e18d04 Remove dead code and extract helper methods
- Delete LocalizationCatalogTests.swift (530 lines entirely commented out)
- Remove unused nilIfEmpty String extension
- Remove backward-compat aliases from CommandProcessor
- Extract reachableTransport/connectedTransport helpers in MessageRouter
- Extract npubToHex/sendWrappedMessage helpers in NostrTransport
- Refactor 7 message sending methods to use shared helpers

Net reduction: ~590 lines

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 14:09:58 -10:00
jackandGitHub 4fbbd24021 Merge pull request #959 from permissionlesstech/fix/tor-foreground-restart-delay
Remove dormant wake attempt on Tor foreground restart
2026-01-14 12:04:10 -10:00
jackandClaude Opus 4.5 ec54877140 Remove dormant wake attempt on Tor foreground restart
Arti's dormant/wake FFI functions are no-op stubs that don't actually
implement dormant mode. The app was wasting 2.5-12 seconds trying to
wake from a dormant state that doesn't exist before falling back to
a full restart.

This change:
- Removes wakeFromDormant() call and function
- Simplifies goDormantOnBackground() to just mark state as not ready
- Goes straight to restartArti() on foreground, eliminating the delay

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:59:24 -10:00
jackandClaude Opus 4.5 293d627c28 Fix compiler warning: use let for reference type array
BitchatMessage is a class, so modifying its properties through an array
reference doesn't mutate the array itself. Changed var to let binding.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:18:16 -10:00
jackandGitHub 0c3f84224c Merge pull request #958 from permissionlesstech/feature/arti-tor-replacement
Replace C Tor with Rust Arti
2026-01-14 10:15:16 -10:00
jackandClaude Opus 4.5 7323c0b96c Fix SPM package path for Arti
Update root Package.swift to reference localPackages/Arti instead of
the removed localPackages/Tor directory.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 16:39:46 -10:00
jack 7bb835ffc9 Merge main into feature/arti-tor-replacement 2026-01-13 16:27:30 -10:00
jackandClaude Opus 4.5 23d63ab4df Replace C Tor with Rust Arti for Tor integration
- Replace C Tor (0.4.8.21) with Rust Arti (1.9.0/arti-client 0.38)
- 70% smaller binary: 21MB xcframework vs 67MB (6.9MB vs 14MB per slice)
- Memory-safe Rust implementation with modern async (tokio)
- Same SOCKS5 proxy interface at 127.0.0.1:39050 for drop-in compatibility
- FFI wrapper (arti-bitchat crate) with C-compatible exports
- Swift TorManager maintains identical public API
- Aggressive size optimization: opt-level=z, lto=fat, panic=abort, strip
- Supports iOS device, iOS simulator (Apple Silicon), and macOS

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 16:18:00 -10:00
jackandGitHub fa3c74f941 Merge pull request #957 from permissionlesstech/fix/noise-protocol-compatibility 2026-01-13 13:07:49 -10:00
callebtc 9bac52051a Revert Noise nonce size to 4 bytes for backward compatibility
This change reverts the nonce size increase introduced in f41a390 ('BCH-01-010: Noise Protocol spec compliance improvements').

While 8-byte nonces are recommended by the Noise specification, increasing the size from 4 bytes broke wire compatibility with existing clients. This caused all encrypted DMs to fail between updated and non-updated clients.

Changes:
- Revert NONCE_SIZE_BYTES to 4.
- Restore 4-byte nonce extraction logic in extractNonceFromCiphertextPayload.
- Restore 4-byte nonce serialization in nonceToBytes.
- Restore UInt32.max overflow check in encrypt.

Note: Other improvements from BCH-01-010 (constant-time comparisons, safe arithmetic in replay window, sensitive data clearing) are preserved.
2026-01-14 05:41:26 +07:00
jackandClaude Opus 4.5 7b9ffe464a Add Tor build script to repository for reproducibility
Include the build-minimal.sh script alongside BITCHAT_TOR.md so future
rebuilds can be done directly from the bitchat repo.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 21:46:48 -10:00
jackandGitHub 7b940485d9 Merge pull request #956 from permissionlesstech/tor-size-optimization
Update Tor to 0.4.8.21 with size optimization
2026-01-12 21:44:23 -10:00
jackandClaude Opus 4.5 5a87ee3e62 Update Tor to 0.4.8.21 with aggressive size optimization
- Update Tor from 0.4.8.17 to 0.4.8.21
- Update OpenSSL from 3.5.1 to 3.6.0
- Aggressive OpenSSL trimming to reduce binary size:
  - Remove post-quantum crypto (ML-DSA, ML-KEM, SLH-DSA, LMS)
  - Remove legacy ciphers (DES, RC2, RC4, RC5, IDEA, SEED, etc.)
  - Remove unused hashes (MD4, MDC2, Whirlpool, RIPEMD160)
  - Remove Chinese standards (SM2, SM3, SM4)
  - Remove certificate features (CMP, CT, RFC3779)
  - Remove GOST, binary EC curves, and other unused features
- Add --disable-module-pow to Tor configure
- Add -Wl,-dead_strip linker flag

Binary size reduction:
- iOS arm64: 17 MB → 14.2 MB (-16%)
- iOS simulator: 16 MB → 13.8 MB (-14%)
- macOS arm64: 16 MB → 13.8 MB (-14%)

Build script at ~/Documents/vibe/Tor.framework-build/build-minimal.sh

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 21:31:10 -10:00
jackandGitHub 342eabbc00 Merge pull request #955 from permissionlesstech/fix/topology-update-after-verification
Fix topology update running before announce verification
2026-01-12 18:39:56 -10:00
jackandClaude Opus 4.5 241ce2d52c Fix topology update running before announce verification
Move updateNeighbors call to after signature verification passes,
preventing spoofed or stale announces from injecting bad routing data.
Also reset isNewPeer/isReconnectedPeer flags on verification failure
to prevent spurious peer connection notifications.

Addresses review feedback from PR #938.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 18:34:59 -10:00
jackandGitHub 18b56e7393 Merge pull request #954 from permissionlesstech/fix/read-receipt-status-update
Fix READ receipt status not updating to blue checks
2026-01-12 18:28:20 -10:00
jackandClaude Opus 4.5 beb04fc887 Fix READ receipt status not updating to blue checks
- Add findMessageIndex helper to handle peer ID format mismatch
  (short 16-hex vs long 64-hex noise key)
- Prevent DELIVERED acks from downgrading .read status back to .delivered
  (fixes race condition where late-arriving delivery acks overwrote read status)
- Use incoming peerID for READ receipts instead of creating new ID from noise key
- Explicitly re-assign messages array to trigger @Published setter

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 18:23:56 -10:00
jackandGitHub 5fcffefa28 Merge pull request #953 from permissionlesstech/remove-security-warning-appinfo
Remove security warning from AppInfo view
2026-01-12 16:25:34 -10:00
jackandClaude Opus 4.5 46ae039587 Remove security warning from AppInfo view
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 16:24:51 -10:00
jackandGitHub 917f7ebe5f Merge pull request #952 from permissionlesstech/fix/file-transfer-peerid-normalization
Fix asymmetric voice/media delivery in private chats
2026-01-12 16:23:12 -10:00
jackandClaude Opus 4.5 b47fb736f4 Fix asymmetric voice/media delivery in private chats
When selectedPrivateChatPeer was migrated to a 64-hex stable Noise key
after session establishment, file transfers would silently fail because:

1. Sender used raw 64-hex key, truncated to first 8 bytes by BinaryProtocol
2. Receiver's myPeerID is SHA256-fingerprint-derived (different value)
3. Recipient check failed, causing silent packet drop

The fix normalizes peerID to short form (SHA256-derived 16-hex) in
sendFilePrivate before constructing recipientData, ensuring the wire
format matches what receivers expect.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 16:17:50 -10:00
jackandGitHub ebb5bb6558 Merge pull request #951 from permissionlesstech/fix/main-actor-isolation-snapshots
Fix main actor isolation error in clearAppSwitcherSnapshots
2026-01-12 16:11:40 -10:00
jackandClaude Opus 4.5 7cfdcfe174 Fix main actor isolation error in clearAppSwitcherSnapshots
Add `nonisolated` to the static method since it only performs
thread-safe FileManager operations and is called from Task.detached.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 16:04:12 -10:00
jackandGitHub 21e0cbc607 Merge pull request #950 from permissionlesstech/fix/var-to-let-warnings
Fix compiler warnings for unmutated variables in BLEService
2026-01-12 15:53:25 -10:00
jackandClaude Opus 4.5 1b439a543e Fix compiler warnings for unmutated variables in BLEService
Change var to let for packet variables that are never mutated.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:44:24 -10:00
jackandGitHub d30a3b14cf Merge pull request #949 from a1denvalu3/main
fix: update georelays weekly workflow
2026-01-12 15:37:14 -10:00
jackandGitHub d03128612d Merge pull request #948 from permissionlesstech/fix/BCH-01-010-noise-protocol-spec-compliance
BCH-01-010: Noise Protocol spec compliance improvements
2026-01-12 15:36:45 -10:00
jackandGitHub ae294aab6d Merge pull request #947 from permissionlesstech/fix/BCH-01-013-clear-snapshots-on-reset
fix(security): clear iOS app switcher snapshots on panic reset [BCH-01-013]
2026-01-12 15:36:01 -10:00
jackandGitHub 07b0e146f2 Merge pull request #946 from permissionlesstech/fix/BCH-01-012-notification-blocking-bypass
fix(security): prevent notifications from blocked users [BCH-01-012]
2026-01-12 15:35:29 -10:00
jackandGitHub cc5939cb13 Merge pull request #945 from permissionlesstech/fix/BCH-01-009-keychain-error-handling
fix(security): improve keychain error handling [BCH-01-009]
2026-01-12 15:34:47 -10:00
jackandGitHub cada784844 Merge pull request #944 from permissionlesstech/fix/BCH-01-011-timestamp-validation
fix(security): reduce timestamp validation window to ±5 minutes [BCH-01-011]
2026-01-12 15:33:38 -10:00
jackandGitHub b1aeb931bc Merge pull request #943 from permissionlesstech/fix/BCH-01-004-fingerprinting-disclosure
fix BCH-01-004: rate-limit subscription-triggered announces
2026-01-12 15:30:15 -10:00
jackandGitHub b675738664 Merge pull request #942 from permissionlesstech/fix/BCH-01-002-file-storage-dos
fix BCH-01-002: prevent DoS via file storage exhaustion
2026-01-12 15:27:51 -10:00
jackandClaude Opus 4.5 4091a30f10 BCH-01-002: Switch from pending files to disk quota management
The original PR introduced a PendingFileManager that held incoming files
in memory until user acceptance. However, this approach had issues:
1. No UI was implemented for users to accept/decline files
2. Files would expire after 5 minutes, losing legitimate transfers
3. The app only allows specific media types (photos, audio) that users
   explicitly choose to send, so manual acceptance adds friction

This commit replaces the pending file system with disk quota management:
- Auto-save files immediately (preserving original UX)
- Enforce 100 MB storage quota for incoming files
- Auto-delete oldest files when quota is exceeded
- Logs cleanup activity for visibility

This directly addresses the DoS vulnerability (disk exhaustion from
file spam) while maintaining good UX for legitimate transfers.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:17:09 -10:00
a1denvalu3anda1denvalu3 238311aefb fix typo 2026-01-13 00:57:20 +01:00
a1denvalu3anda1denvalu3 6defae71c6 fetch georelays working workflow 2026-01-13 00:27:10 +01:00
jackandClaude Opus 4.5 b533d9560d Fix: Capture handshake hash before split() clears symmetric state
Addresses Codex review feedback on PR #948: the getTransportCiphers()
method now returns the handshake hash as part of its return tuple,
capturing it BEFORE split() calls clearSensitiveData().

Previously, calling getHandshakeHash() after getTransportCiphers()
would return an all-zero value since split() clears the symmetric
state. This broke channel binding functionality.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 11:32:17 -10:00
jackandClaude Opus 4.5 f41a390a94 BCH-01-010: Noise Protocol spec compliance improvements
This addresses the Cure53 audit finding BCH-01-010 which identified several
implementation deviations from the Noise Protocol Framework specification.

Changes:
- Expand nonce from 4-byte to 8-byte transmission per spec guidance
  This provides 2^64 nonce space instead of the limited 2^32 space
- Fix integer overflow in replay window check using safe arithmetic
- Add constant-time comparison functions for key validation to prevent
  timing side-channel attacks
- Add clearSensitiveData() method to NoiseSymmetricState that clears
  chaining key and hash after split() operation
- Document atomic nonce state updates in decryption

Security improvements:
- Constant-time operations prevent information leakage via timing analysis
- Proper cleanup of symmetric state after handshake completion
- Safer arithmetic prevents potential integer overflow issues

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 11:23:31 -10:00
jackandClaude Opus 4.5 f83316bd1f fix(security): clear iOS app switcher snapshots on panic reset [BCH-01-013]
Add code to clear iOS app switcher snapshot cache during panic mode.
iOS automatically captures screenshots for the app switcher, which could
reveal sensitive message content visible at the time.

Changes:
- Add clearAppSwitcherSnapshots() helper function (iOS only)
- Call it from panicClearAllData() during file cleanup phase
- Clears all files in Library/Caches/Snapshots/ directory

Security: Sensitive information visible in the app when it entered
background will no longer be recoverable from snapshot cache after
panic reset.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 11:10:34 -10:00
jackandClaude Opus 4.5 09818a02ed fix(security): prevent notifications from blocked users [BCH-01-012]
Block notifications from bypassing the user blocking feature on iOS.

Vulnerabilities fixed:
1. Nostr public messages: checkForMentions() was called regardless of
   blocking status, allowing blocked users to trigger mention notifications
2. Noise-encrypted DMs: didReceiveNoisePayload() didn't check blocking
   before calling handlePrivateMessage(), allowing DM notifications

Changes:
- ChatViewModel+Nostr.swift: Add blocking check before checkForMentions()
  and sendHapticFeedback() in subscribeNostrEvent
- ChatViewModel.swift: Add isPeerBlocked() check in didReceiveNoisePayload
  before processing private messages
- Add NotificationBlockingTests.swift with 5 tests for blocking behavior

Security: Blocked users can no longer trigger notifications through any
message path (Nostr public, Nostr DM, or BLE Noise DM).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 11:08:09 -10:00
jackandClaude Opus 4.5 9cd955ae2f fix: extend backoff window on blocked attempts (Codex P2 feedback)
Update lastAnnounceTime to 'now' when rate-limiting subscription attempts.
This ensures each blocked attempt extends the suppression window, preventing
attackers from waiting out the backoff while continuously spamming attempts.

Previously, the backoff timer was only based on the last successful announce,
allowing attackers to receive an announce as soon as the initial backoff
expired regardless of how many blocked attempts occurred in between.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 11:00:36 -10:00
jackandClaude Opus 4.5 49b1413d85 fix: address Codex review feedback for BCH-01-002
P1: Wire didReceivePendingFileTransfer into ChatViewModel
- Add implementation that creates system message for pending files
- Route to appropriate chat (public/private)
- Send local notification for incoming files
- Auto-decline files from blocked users

P2: Keep pending files in queue if save fails
- Only remove file from pendingFiles after successful save
- Allows user to retry accept if initial save failed

Also adds test for save failure scenario.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 10:58:02 -10:00
jackandClaude Opus 4.5 31be6b83a7 fix(security): improve keychain error handling [BCH-01-009]
Add proper error classification to distinguish expected states (item not
found) from critical failures (access denied, storage full) and
recoverable errors (device locked, auth failed).

Changes:
- Add KeychainReadResult and KeychainSaveResult enums with error types
- Add getIdentityKeyWithResult/saveIdentityKeyWithResult protocol methods
- Implement retry logic with exponential backoff for transient errors
- Add consistent SecureLogger logging for all keychain operations
- Update NoiseEncryptionService identity loading to handle errors gracefully
- Update MockKeychain and TrackingMockKeychain with error simulation
- Add comprehensive KeychainErrorHandlingTests (18 new tests)

Security: Applications can now properly detect and respond to critical
keychain failures, improving resilience during device lock states and
providing actionable diagnostics for access issues.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 10:53:02 -10:00
jackandClaude Opus 4.5 1563209797 fix(security): reduce timestamp validation window to ±5 minutes
BCH-01-011: Reduces replay attack window from ±1 hour to ±5 minutes.

The previous 1-hour window allowed extended replay attacks. A 5-minute
window is industry standard for replay protection while accommodating
reasonable clock drift.

- Updated InputValidator.validateTimestamp to use 300-second window
- Updated tests to verify new boundary conditions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 10:46:34 -10:00
jackandClaude Opus 4.5 99896bcded fix BCH-01-004: rate-limit subscription-triggered announces
Adds rate-limiting to prevent device enumeration attacks via rapid
subscription/disconnect cycles. Without this fix, an attacker could
enumerate ~120 bitchat devices per minute by connecting, subscribing,
capturing the announce packet, then disconnecting and moving to the next.

Key changes:
- Add per-central rate-limit tracking with exponential backoff
- Minimum 2-second interval between announces per central
- Exponential backoff (2x) up to 30 seconds for rapid reconnects
- After 5 rapid attempts, suppress announces entirely (likely attack)
- Clean up stale rate-limit entries after 60-second window
- Clear rate-limit state during panic mode

Configuration:
- bleSubscriptionRateLimitMinSeconds: 2.0
- bleSubscriptionRateLimitBackoffFactor: 2.0
- bleSubscriptionRateLimitMaxBackoffSeconds: 30.0
- bleSubscriptionRateLimitWindowSeconds: 60.0
- bleSubscriptionRateLimitMaxAttempts: 5

Security audit reference: Cure53 BCH-01-004 (Medium severity)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 10:43:36 -10:00
jackandClaude Opus 4.5 4b3077169a fix BCH-01-002: prevent DoS via file storage exhaustion
Incoming files are now held in memory via PendingFileManager instead of
being auto-saved to disk. Users must explicitly accept files before they
are written. This prevents attackers from exhausting device storage.

Key changes:
- Add PendingFileManager with configurable limits (max 10 files, 5MB total)
- Files auto-expire after 5 minutes if not accepted
- LRU eviction when limits are exceeded
- Pending files cleared during panic mode (emergencyDisconnectAll)
- Add didReceivePendingFileTransfer delegate method
- Add acceptPendingFile/declinePendingFile to Transport protocol

Security audit reference: Cure53 BCH-01-002 (Medium severity)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 10:39:19 -10:00
jackandGitHub b84c36c6fa Merge pull request #934 from 21-DOT-DEV/P256K-VERSION-LOCK
chore: pin swift-secp256k1 to exact version 0.21.1
2026-01-12 10:24:02 -10:00
jackandGitHub 3eac5858e4 Merge pull request #938 from permissionlesstech/source-routing-packet-format
feat: Source routing v2
2026-01-12 10:21:28 -10:00
jackandGitHub 10b7c1fd80 Merge branch 'main' into source-routing-packet-format 2026-01-12 10:09:29 -10:00
jackandGitHub bf3249aef7 Merge pull request #940 from permissionlesstech/geohash-presence-events
Implement Geohash Presence (Heartbeats)
2026-01-12 10:05:51 -10:00
jackandClaude Opus 4.5 95a6ec7315 add tests for geohash presence feature
34 tests covering:
- NostrProtocol presence event creation
- NostrFilter kind filtering
- ChatViewModel presence handling
- Privacy precision restrictions
- Display logic for "? people"
- Participant tracker integration

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 09:56:51 -10:00
callebtc 74864472c7 fix relay 2026-01-12 17:04:17 +07:00
callebtc 9404c03477 fix doc 2026-01-12 16:56:45 +07:00
callebtc 6faa46a22f increase limit to 1000 2026-01-12 16:41:37 +07:00
callebtc e02f4327c0 move block up 2026-01-12 14:57:25 +07:00
callebtc ce31d85323 record participant count before self-suppression 2026-01-12 14:33:21 +07:00
callebtc b6d8a5b758 update UI on new data 2026-01-12 14:14:34 +07:00
callebtc 6630f5a792 start the service 2026-01-12 14:13:24 +07:00
callebtc 0f5299a0f5 announce and read presence 2026-01-12 14:12:49 +07:00
callebtc eb3bbfd861 source routing v2 2026-01-12 10:18:04 +07:00
callebtc a37243e780 min chunk size 2026-01-10 17:23:52 +07:00
callebtc 90b134186b dynamic fragmentation 2026-01-09 22:54:03 +07:00
callebtc 3c7e14f49d fixes 2026-01-09 16:03:17 +07:00
callebtc 31275856dd resign packet 2026-01-08 16:04:40 +07:00
callebtc b6cf44a824 centralize apply route 2026-01-07 19:16:18 +07:00
callebtc b6ae08be60 route length is part of header length now 2026-01-07 16:08:02 +07:00
callebtc d469704c34 wip: SBR only for v2 2026-01-07 15:11:46 +07:00
csjones c975abf2ff chore: pin swift-secp256k1 to exact version 0.21.1 2026-01-05 15:28:26 -08:00
jackandGitHub aff700a15e Merge pull request #931 from permissionlesstech/fix/flaky-fragmentation-tests
fix: eliminate flaky FragmentationTests with proper async synchronization
2026-01-04 14:25:07 -10:00
jackandClaude Opus 4.5 869d766f8d fix: address race condition in continuation-based waiting
Add recheck of message count after acquiring lock and before storing
continuation to prevent race where message arrives between initial
count check and continuation install.

Addresses Codex review feedback on PR #931.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 14:18:40 -10:00
jackandClaude Opus 4.5 1b4f120014 fix: eliminate flaky FragmentationTests with proper async synchronization
Replace timing-based sleep() synchronization with continuation-based waiting
in FragmentationTests to fix intermittent failures.

Changes:
- Add thread-safe CaptureDelegate with NSLock for array access
- Add waitForPublicMessages/waitForReceivedMessages using CheckedContinuation
- Update reassemblyFromFragmentsDeliversPublicMessage to use proper waiting
- Update duplicateFragmentDoesNotBreakReassembly to use proper waiting
- Remove fire-and-forget Task blocks that caused race conditions

Root cause: Tests used fire-and-forget Tasks with sleep(0.5) but delegate
callbacks go through notifyUI() which spawns another MainActor Task.
The sleep didn't guarantee callback completion.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 14:13:32 -10:00
jackandGitHub bc312e4aef Merge pull request #929 from permissionlesstech/fix/nostr-transport-thread-safety
fix: add thread safety to NostrTransport read receipt queue
2026-01-04 13:56:17 -10:00
jackandGitHub 6e7509f2be Merge branch 'main' into fix/nostr-transport-thread-safety 2026-01-04 13:52:31 -10:00
jackandGitHub ca06f4d51d Merge pull request #928 from permissionlesstech/fix/noise-dh-secret-clearing
fix: clear DH shared secrets after Noise handshake operations
2026-01-04 13:52:14 -10:00
jackandGitHub 04f57e8713 Merge branch 'main' into fix/nostr-transport-thread-safety 2026-01-04 13:41:53 -10:00
jackandGitHub 59c3c4e236 Merge branch 'main' into fix/noise-dh-secret-clearing 2026-01-04 13:41:38 -10:00
jackandClaude Opus 4.5 e887e04f40 fix: add thread safety to NostrTransport read receipt queue
Synchronized access to readQueue and isSendingReadAcks using the
existing concurrent DispatchQueue with barrier flags:

- sendReadReceipt(): wrap enqueue in barrier async
- processReadQueueIfNeeded(): extract item within barrier context
- scheduleNextReadAck(): wrap callback in barrier async

This fixes race conditions where concurrent calls could corrupt the
read queue or cause check-then-act bugs on isSendingReadAcks.

Also adds thread safety tests:
- concurrentReadReceiptEnqueue: 100 concurrent enqueue operations
- readQueueProcessingUnderLoad: concurrent enqueue during processing
- isPeerReachableThreadSafety: concurrent read access test

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 13:29:20 -10:00
jackandClaude Opus 4.5 151b68a497 fix: clear DH shared secrets after Noise handshake operations
Added secureClear() calls for all 6 DH operations in NoiseProtocol.swift
to properly clear sensitive shared secrets from memory after use:

- writeMessage(): .es initiator/responder, .se initiator/responder
- performDHOperation(): .ee and .ss operations

This fixes a forward secrecy vulnerability where shared secrets could
persist in memory after handshake completion.

Also adds:
- TrackingMockKeychain to count secureClear calls in tests
- 3 new tests verifying secureClear is called during handshake

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 13:28:55 -10:00
jackandClaude Opus 4.5 b4a3ee5777 fix: add @MainActor to MessageDeduplicationService for thread safety
Added @MainActor annotation to both LRUDeduplicationCache and
MessageDeduplicationService classes. This provides compile-time
enforcement of thread safety since all callers (ChatViewModel,
NostrRelayManager) are already on MainActor.

Benefits:
- Compile-time enforcement prevents future misuse
- Simpler than internal locking mechanisms
- Consistent with existing patterns in codebase

Also adds:
- @MainActor annotation to existing test suites
- 5 new concurrency tests verifying thread safety behavior

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 13:25:43 -10:00
jackandClaude Opus 4.5 8d19d6d62c fix: add thread safety to NostrTransport read receipt queue
Synchronized access to readQueue and isSendingReadAcks using the
existing concurrent DispatchQueue with barrier flags:

- sendReadReceipt(): wrap enqueue in barrier async
- processReadQueueIfNeeded(): extract item within barrier context
- scheduleNextReadAck(): wrap callback in barrier async

This fixes race conditions where concurrent calls could corrupt the
read queue or cause check-then-act bugs on isSendingReadAcks.

Also adds thread safety tests:
- concurrentReadReceiptEnqueue: 100 concurrent enqueue operations
- readQueueProcessingUnderLoad: concurrent enqueue during processing
- isPeerReachableThreadSafety: concurrent read access test

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 13:23:32 -10:00
jackandClaude Opus 4.5 84fd92ef4b fix: clear DH shared secrets after Noise handshake operations
Added secureClear() calls for all 6 DH operations in NoiseProtocol.swift
to properly clear sensitive shared secrets from memory after use:

- writeMessage(): .es initiator/responder, .se initiator/responder
- performDHOperation(): .ee and .ss operations

This fixes a forward secrecy vulnerability where shared secrets could
persist in memory after handshake completion.

Also adds:
- TrackingMockKeychain to count secureClear calls in tests
- 3 new tests verifying secureClear is called during handshake

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 13:21:01 -10:00
jackandGitHub f71bd506fd Merge pull request #920 from malkovitc/feat/472-optimize-message-deduplicator
perf: optimize MessageDeduplicator performance
2026-01-04 12:41:48 -10:00
jackandGitHub ddd7ef5668 Merge branch 'main' into feat/472-optimize-message-deduplicator 2026-01-04 12:37:01 -10:00
jackandGitHub 548a20e77d Merge pull request #919 from malkovitc/fix/646-harden-hex-parsing
fix: harden hex string parsing
2026-01-04 12:33:16 -10:00
jackandGitHub 6e231d10c5 Merge branch 'main' into fix/646-harden-hex-parsing 2026-01-04 12:28:39 -10:00
jackandGitHub 4c9f6e689e Merge pull request #918 from malkovitc/fix/voice-recorder-simulator-build
fix(build): exclude allowBluetoothHFP on iOS Simulator
2026-01-04 12:25:11 -10:00
jackandGitHub 7e73b65240 Merge branch 'main' into fix/voice-recorder-simulator-build 2026-01-04 12:19:52 -10:00
jackandGitHub f5e5f7b98e Merge pull request #916 from malkovitc/fix/797-consolidate-keychain
refactor: consolidate KeychainHelper into KeychainManager
2026-01-04 12:13:15 -10:00
evgeniy.chernomortsev b15d92ebb5 perf: optimize MessageDeduplicator performance (#472)
- Make Entry struct Equatable for better testability
- Remove down to 75% of maxCount instead of fixed 100 items for better
  amortization of cleanup cost
- Reuse Date instance to reduce allocations in isDuplicate()
- Add documentation comments for public methods
- Improve memory capacity management in cleanup()
2025-12-09 18:48:14 +04:00
evgeniy.chernomortsev 6efe9d02fb fix: harden hex string parsing (#646)
Improve Data(hexString:) to handle edge cases:
- Reject odd-length strings (return nil)
- Support optional 0x/0X prefix
- Trim leading/trailing whitespace
- Handle empty strings correctly

Add comprehensive unit tests for hex parsing.
2025-12-09 15:01:59 +04:00
evgeniy.chernomortsev 5a66f03400 fix(build): exclude allowBluetoothHFP on iOS Simulator
allowBluetoothHFP is not available on iOS Simulator, causing build
failures. Use conditional compilation to exclude this option when
building for simulator while keeping it for device builds.
2025-12-09 14:50:34 +04:00
evgeniy.chernomortsevandClaude Opus 4.5 a221b22691 refactor: consolidate KeychainHelper into KeychainManager (#797)
Merge KeychainHelper functionality into KeychainManager to provide
a single, unified API for all keychain operations.

- Remove KeychainHelper.swift and KeychainHelperProtocol
- Add generic save/load/delete methods to KeychainManagerProtocol
- Update NostrIdentityBridge to use KeychainManagerProtocol
- Update FavoritesPersistenceService to use KeychainManagerProtocol
- Update PreviewKeychainManager with new methods
- Update MockKeychain and add MockKeychainHelper typealias for backwards compatibility

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Add corrections code in refactory and enum.

* Correct code in command enum and ContentView.

* Dedicated CommandSuggestionsView to extract code

* Simplify CommandInfo + minor optimization

* Fix preview

---------

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

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

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

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

Fixes #785

* Save identity state during verification events

---------

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

* include neighbors in ANNOUNCE

---------

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

* Extract public message pipeline

* Fix Swift concurrency warnings

* Incorporate public chat review feedback

* Tighten nostr key removal loop

* Address review feedback

---------

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

* Guard BLE restoration paths to iOS

---------

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

* Consolidate notification logic and add InputValidator monitoring

Follow-up improvements to address PR feedback:

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

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

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

---------

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

* Reserve media slots atomically

* Prioritize small fragment trains

---------

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

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

* `handleFileTransfer` to use PeerID

* `sendMessage` and `sendPrivateMessage`

* PeerID 30/n: Update some leftovers

* `lowercased()` inside PeerID for normalization

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

* MockBLEService: Remove direct target delivery

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

---------

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

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

* `handleFileTransfer` to use PeerID

* `sendMessage` and `sendPrivateMessage`

* PeerID 30/n: Update some leftovers

* `lowercased()` inside PeerID for normalization

---------

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

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

* `handleFileTransfer` to use PeerID

* `sendMessage` and `sendPrivateMessage`

---------

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

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

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

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

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

* Gracefully disable mac attachment pickers in sandbox

* Tighten spacing above media message bubbles

* Reduce vertical padding between chat rows

* Restore iOS file importer for attachments

* Copy imported files before sending to preserve access

* Allow file transfers from connected but unverified peers

* Raise BLE notification buffer cap for large file transfers

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

This reverts commit b624523af843475db84e4a846db8dcbe824ae408.

* Add guard to drop oversized BLE notification assemblies

* Let BLE assembler accept large frames up to hard cap

* Add detailed logging for BLE fragment assembly

* Log incomplete BLE frames for debugging

* Stop dropping partial BLE frames while assembling notifications

* Fix compressed BLE file transfers

* Enable mac attachment importers

* Allow mac microphone access

* Permit mac media library access

* Describe microphone usage

* Harden attachment transfer bookkeeping

* Display recording milliseconds

* Restore mac photo picker access

* Use Photos picker on mac

* Allow long-press reblur on images

* Reblur images via swipe

* Lowercase image preview buttons

* Keep processed images for outgoing messages

* Use save panel for mac image export

* Fix image attachment detection

* Allow user-selected write access

* Align mac image JPEG encoding

* Revert unsupported JPEG option

* Strip metadata in mac image encoding

* Normalize mac JPEG color space

* Target image byte size across platforms

* Fix CFMutableData handling

* Preserve packet version when signing

* Use unique transfer identifiers

* Stub file transfer methods in mock

* Stub file transfer methods in mock

* Hide absolute paths in media messages

* Resolve image/voice path handling

* Fix cleanupLocalFile lookup

* Restore BLE broadcasts when notify buffer is saturated

* Guard peer map reads on BLE message path

* Drop attachment ceilings to 1 MiB and bump release version

* Reset BLE assembler on stalled fragment trains

* Fix binary protocol test fixtures

* Fix critical issues from PR #681 review

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

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

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

All tests passing.

* Fix UI freeze when receiving voice notes

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

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

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

* Fix memory leaks and post-playback freeze

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

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

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

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

Memory should now remain stable during extended use.

* Eliminate disk I/O from SwiftUI view rendering path

Critical performance fix for UI freezes when receiving media:

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

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

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

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

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

* Cache Nostr identity derivation to prevent crypto during view rendering

Critical performance fix:

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

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

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

This eliminates crypto from the hot rendering path.

* Cache geohash identity in ChatViewModel to prevent crypto during rendering

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

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

* Make voice note loading completely lazy with deferred initialization

Aggressive performance optimization to prevent UI freezes:

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

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

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

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

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

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

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

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

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

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

* Fix infinite render loop and apply all security fixes

CRITICAL BUG FIX - Infinite Render Loop:

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

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

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

PERFORMANCE FIXES:

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

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

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

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

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

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

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

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

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

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

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

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

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

Production ready.

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

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

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

* Remove debug print statements from sendMessage

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

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

* Fix critical security issues in fragment reassembly and file cleanup

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

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

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

* Fix post-rebase compilation errors

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

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

* Fix remaining compilation issues after rebase

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

Build and tests now passing.

* Add proper availability checks for PhotosPickerItem

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

This fixes CI build failures on older macOS environments.

* Limit PhotosPicker to iOS only to fix CI

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

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

Fixes CI build failures.

* Convert new tests to Swift Testing

* Fix compilation issue

* Add the missing `fileTransfer` case

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

* Revive lost `NotificationStreamAssembler` changes

* Allow file fragments to account for protocol overhead

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

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

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

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

* Improve camera UX: Direct camera access with camera icon

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

* Full-screen camera with photo library option

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

* Fix camera white bars with overFullScreen presentation

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

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

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

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

* Simplify camera presentation to reduce frame errors

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

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

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

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

* Optimize sheet presentation for camera UI

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

* Fix P1: Add DoS protections to PeerID fragment handler

Critical security fix addressing Codex review feedback:

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

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

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

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

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

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

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

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

* Fix critical thread-safety crash in PeerID fragment handler

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

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

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

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

* Remove Localizable.xcstrings formatting noise

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

This was just Xcode reformatting with no actual string changes.

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

* Add macOS photo picker support

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

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

* Add Localizable strings for camera and voice features

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

* SwiftTesting: `NotificationStreamAssemblerTests`

* SwiftTesting: `NostrProtocolTests`

* SwiftTesting: `BinaryProtocolTests`

* SwiftTesting: `PeerIDTests`

* SwiftTesting: `BLEServiceTests`

* SwiftTesting: `CommandProcessorTests`

* SwiftTesting: `GCSFilterTests`

* SwiftTesting: `GeohashBookmarksStoreTests`

* Remove `peerID` test constants

* Remove PeerID + String interop from tests

* Refactor IntegrationTests to extract state management

* Refactor global state management of MockBLEService

* NoiseProtocolSwiftTests: `actor` -> `struct`

* Remove measurement tests w/ no benchmark

* `NoiseProtocolSwiftTests` -> `NoiseProtocolTests`

* SwiftTesting: `LocationChannelsTests`

* SwiftTesting: `GossipSyncManagerTests`

* SwiftTesting: `LocationNotesManagerTests`

* Global `sleep` function for tests

* SwiftTesting: `IntegrationTests`

---------

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

* NoiseSessionManager: Remove unused functions

---------

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

* Swipe right to return from DM to people list

---------

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

* Gate stale gossip announcements

* Remove stale peer messages during gossip cleanup

---------

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

* Add Turkey to knownRegions

---------

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

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

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

* `KeychainHelper` behind a protocol to easily mock

* Update tests with ID Bridge and MockKeychainHelper

---------

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

* Optimize debug logo + remove its duplicates
2025-10-11 21:28:28 +02:00
2640 changed files with 81825 additions and 395855 deletions
@@ -1,12 +0,0 @@
---
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/Swift.swiftmodule/arm64e-apple-macos.swiftmodule'
dependencies:
- mtime: 1757258659000000000
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/Swift.swiftmodule/arm64e-apple-macos.swiftmodule'
size: 14166264
- mtime: 1754189697000000000
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
size: 2261306
sdk_relative: true
version: 1
...
@@ -1,16 +0,0 @@
---
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/SwiftOnoneSupport.swiftmodule/arm64e-apple-macos.swiftmodule'
dependencies:
- mtime: 1757258662000000000
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/SwiftOnoneSupport.swiftmodule/arm64e-apple-macos.swiftmodule'
size: 18068
- mtime: 1754189697000000000
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
size: 2261306
sdk_relative: true
- mtime: 1754191141000000000
path: 'usr/lib/swift/SwiftOnoneSupport.swiftmodule/arm64e-apple-macos.swiftinterface'
size: 1224
sdk_relative: true
version: 1
...
@@ -1,16 +0,0 @@
---
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_Concurrency.swiftmodule/arm64e-apple-macos.swiftmodule'
dependencies:
- mtime: 1757258669000000000
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_Concurrency.swiftmodule/arm64e-apple-macos.swiftmodule'
size: 699544
- mtime: 1754189697000000000
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
size: 2261306
sdk_relative: true
- mtime: 1754192470000000000
path: 'usr/lib/swift/_Concurrency.swiftmodule/arm64e-apple-macos.swiftinterface'
size: 364219
sdk_relative: true
version: 1
...
@@ -1,16 +0,0 @@
---
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_StringProcessing.swiftmodule/arm64e-apple-macos.swiftmodule'
dependencies:
- mtime: 1757258664000000000
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_StringProcessing.swiftmodule/arm64e-apple-macos.swiftmodule'
size: 83568
- mtime: 1754189697000000000
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
size: 2261306
sdk_relative: true
- mtime: 1754192532000000000
path: 'usr/lib/swift/_StringProcessing.swiftmodule/arm64e-apple-macos.swiftinterface'
size: 24507
sdk_relative: true
version: 1
...
+4 -2
View File
@@ -7,6 +7,7 @@ on:
permissions: permissions:
contents: write contents: write
pull-requests: write
jobs: jobs:
update-relay-data: update-relay-data:
@@ -17,10 +18,11 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Fetch GeoRelays - name: Fetch GeoRelays
run: | run: |
wget https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv wget -q https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
mv nostr_relays.csv ./relays/online_relays_gps.csv mv nostr_relays.csv ./relays/online_relays_gps.csv
- name: Check for changes - name: Check for changes
+21 -6
View File
@@ -5,14 +5,23 @@ on:
branches: branches:
- main - main
pull_request: pull_request:
branches:
- main
jobs: jobs:
test: test:
name: Run Swift Tests name: Run Swift Tests (${{ matrix.name }})
runs-on: macos-latest runs-on: macos-latest
strategy:
fail-fast: false # Don't cancel other matrix jobs when one fails
matrix:
include:
- name: app
path: .
- name: BitLogger
path: localPackages/BitLogger
- name: BitFoundation
path: localPackages/BitFoundation
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v5
@@ -20,8 +29,14 @@ jobs:
- name: Set up Swift - name: Set up Swift
uses: swift-actions/setup-swift@v2 uses: swift-actions/setup-swift@v2
- name: Build the package - name: Cache build artifacts
run: swift build uses: actions/cache@v4
with:
path: ${{ matrix.path }}/.build
key: ${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/*.swift', matrix.path), format('{0}/**/Package.resolved', matrix.path)) }}
restore-keys: |
${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/Package.resolved', matrix.path)) }}
${{ runner.os }}-${{ matrix.name }}-
- name: Run Tests - name: Run Tests
run: swift test --parallel run: swift test --parallel --quiet --package-path ${{ matrix.path }}
+4
View File
@@ -8,6 +8,7 @@ plans/
## AI ## AI
CLAUDE.md CLAUDE.md
AGENTS.md AGENTS.md
.claude/
## 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
@@ -66,6 +67,9 @@ __pycache__/
*.tmp *.tmp
*.temp *.temp
## Cache
.cache/
# Local build results # Local build results
.Result*/ .Result*/
.Result*.xcresult/ .Result*.xcresult/
+2 -1
View File
@@ -1,4 +1,4 @@
MARKETING_VERSION = 1.4.4 MARKETING_VERSION = 1.5.1
CURRENT_PROJECT_VERSION = 1 CURRENT_PROJECT_VERSION = 1
IPHONEOS_DEPLOYMENT_TARGET = 16.0 IPHONEOS_DEPLOYMENT_TARGET = 16.0
@@ -9,3 +9,4 @@ DEVELOPMENT_TEAM = L3N5LHJD5Y
CODE_SIGN_STYLE = Automatic CODE_SIGN_STYLE = Automatic
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat
APP_GROUP_ID = group.chat.bitchat
+6 -3
View File
@@ -14,8 +14,11 @@ default:
# Check prerequisites # Check prerequisites
check: check:
@echo "Checking prerequisites..." @echo "Checking prerequisites..."
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ Xcode not found. Install Xcode from App Store" && exit 1) @command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install Xcode from App Store" && exit 1)
@security find-identity -v -p codesigning | grep -q "Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0) @xcode-select -p | grep -q "Xcode.app" || (echo "❌ Full Xcode required, not just command line tools. Install from App Store and run:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
@test -d "/Applications/Xcode.app" || (echo "❌ Xcode.app not found in Applications folder. Install from App Store" && exit 1)
@xcodebuild -version >/dev/null 2>&1 || (echo "❌ Xcode not properly configured. Try:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
@security find-identity -v -p codesigning | grep -q "Apple Development\|Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
@echo "✅ All prerequisites met" @echo "✅ All prerequisites met"
# Backup original files # Backup original files
@@ -44,7 +47,7 @@ patch-for-macos: backup
# 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
+13 -5
View File
@@ -16,7 +16,8 @@ let package = Package(
), ),
], ],
dependencies:[ dependencies:[
.package(path: "localPackages/Tor"), .package(path: "localPackages/Arti"),
.package(path: "localPackages/BitFoundation"),
.package(path: "localPackages/BitLogger"), .package(path: "localPackages/BitLogger"),
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1") .package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
], ],
@@ -25,16 +26,19 @@ let package = Package(
name: "bitchat", name: "bitchat",
dependencies: [ dependencies: [
.product(name: "P256K", package: "swift-secp256k1"), .product(name: "P256K", package: "swift-secp256k1"),
.product(name: "BitFoundation", package: "BitFoundation"),
.product(name: "BitLogger", package: "BitLogger"), .product(name: "BitLogger", package: "BitLogger"),
.product(name: "Tor", package: "Tor") .product(name: "Tor", package: "Arti")
], ],
path: "bitchat", path: "bitchat",
exclude: [ exclude: [
"Info.plist", "Info.plist",
"Assets.xcassets", "Assets.xcassets",
"_PreviewHelpers/PreviewAssets.xcassets",
"bitchat.entitlements", "bitchat.entitlements",
"bitchat-macOS.entitlements", "bitchat-macOS.entitlements",
"LaunchScreen.storyboard" "LaunchScreen.storyboard",
"ViewModels/Extensions/README.md"
], ],
resources: [ resources: [
.process("Localizable.xcstrings") .process("Localizable.xcstrings")
@@ -42,14 +46,18 @@ let package = Package(
), ),
.testTarget( .testTarget(
name: "bitchatTests", name: "bitchatTests",
dependencies: ["bitchat"], dependencies: [
"bitchat",
.product(name: "BitFoundation", package: "BitFoundation")
],
path: "bitchatTests", path: "bitchatTests",
exclude: [ exclude: [
"Info.plist", "Info.plist",
"README.md" "README.md"
], ],
resources: [ resources: [
.process("Localization") .process("Localization"),
.process("Noise")
] ]
) )
] ]
-3
View File
@@ -8,9 +8,6 @@ A decentralized peer-to-peer messaging app with dual transport architecture: loc
📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622) 📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622)
> [!WARNING]
> Private messages have not received external security review and may contain vulnerabilities. Do not use for sensitive use cases, and do not rely on its security until it has been reviewed. Now uses the [Noise Protocol](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.
+66 -11
View File
@@ -10,11 +10,12 @@
17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
3EE336D150427F736F32B56C /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = B1D9136AA0083366353BFA2F /* P256K */; }; 3EE336D150427F736F32B56C /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = B1D9136AA0083366353BFA2F /* P256K */; };
885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; }; 885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; };
A6BCF9482F80953E001CF9B9 /* BitFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = A6BCF9472F80953E001CF9B9 /* BitFoundation */; };
A6BCF94A2F809550001CF9B9 /* BitFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = A6BCF9492F809550001CF9B9 /* BitFoundation */; };
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E56F2E77036A0032EA8A /* BitLogger */; }; A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E56F2E77036A0032EA8A /* BitLogger */; };
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E5712E7703760032EA8A /* BitLogger */; }; A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E5712E7703760032EA8A /* BitLogger */; };
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA7E2E7706720032EA8A /* Tor */; }; A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA7E2E7706720032EA8A /* Tor */; };
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA802E7706A80032EA8A /* Tor */; }; A6E3EA812E7706A80032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA802E7706A80032EA8A /* Tor */; };
A6F183FD2E948783006A9046 /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A6F183FC2E948783006A9046 /* tor-nolzma.xcframework */; };
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; }; E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; }; E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
@@ -95,6 +96,24 @@
); );
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */; target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
}; };
C5E027A52ECCDFD700BD6012 /* Exceptions for "bitchatTests" folder in "bitchatTests_macOS" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
Localization/PrimaryLocalizationKeys.json,
README.md,
);
target = 47FF23248747DD7CB666CB91 /* bitchatTests_macOS */;
};
C5E027A82ECCDFE200BD6012 /* Exceptions for "bitchatTests" folder in "bitchatTests_iOS" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
Localization/PrimaryLocalizationKeys.json,
README.md,
);
target = 6CB97DF2EA57234CB3E563B8 /* bitchatTests_iOS */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -118,6 +137,10 @@
}; };
A6E32D412E762EAE0032EA8A /* bitchatTests */ = { A6E32D412E762EAE0032EA8A /* bitchatTests */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
C5E027A82ECCDFE200BD6012 /* Exceptions for "bitchatTests" folder in "bitchatTests_iOS" target */,
C5E027A52ECCDFD700BD6012 /* Exceptions for "bitchatTests" folder in "bitchatTests_macOS" target */,
);
path = bitchatTests; path = bitchatTests;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -135,15 +158,16 @@
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */, A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */,
3EE336D150427F736F32B56C /* P256K in Frameworks */, 3EE336D150427F736F32B56C /* P256K in Frameworks */,
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */, A6E3EA812E7706A80032EA8A /* Tor in Frameworks */,
A6BCF94A2F809550001CF9B9 /* BitFoundation in Frameworks */,
); );
}; };
B5A5CC493FFB3D8966548140 /* Frameworks */ = { B5A5CC493FFB3D8966548140 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
files = ( files = (
A6F183FD2E948783006A9046 /* tor-nolzma.xcframework in Frameworks */,
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */, A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */,
885BBED78092484A5B069461 /* P256K in Frameworks */, 885BBED78092484A5B069461 /* P256K in Frameworks */,
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */, A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */,
A6BCF9482F80953E001CF9B9 /* BitFoundation in Frameworks */,
); );
}; };
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
@@ -203,6 +227,7 @@
B1D9136AA0083366353BFA2F /* P256K */, B1D9136AA0083366353BFA2F /* P256K */,
A6E3E5712E7703760032EA8A /* BitLogger */, A6E3E5712E7703760032EA8A /* BitLogger */,
A6E3EA802E7706A80032EA8A /* Tor */, A6E3EA802E7706A80032EA8A /* Tor */,
A6BCF9492F809550001CF9B9 /* BitFoundation */,
); );
productName = bitchat_macOS; productName = bitchat_macOS;
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */; productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
@@ -213,6 +238,7 @@
buildConfigurationList = 1C27B5BA3DB46DDF0DBFEF62 /* Build configuration list for PBXNativeTarget "bitchatTests_macOS" */; buildConfigurationList = 1C27B5BA3DB46DDF0DBFEF62 /* Build configuration list for PBXNativeTarget "bitchatTests_macOS" */;
buildPhases = ( buildPhases = (
5C22AA7B9ACC5A861445C769 /* Sources */, 5C22AA7B9ACC5A861445C769 /* Sources */,
C5E027A42ECCDFD700BD6012 /* Resources */,
); );
buildRules = ( buildRules = (
); );
@@ -245,6 +271,7 @@
buildConfigurationList = 38C4AF6313E5037F25CEF30B /* Build configuration list for PBXNativeTarget "bitchatTests_iOS" */; buildConfigurationList = 38C4AF6313E5037F25CEF30B /* Build configuration list for PBXNativeTarget "bitchatTests_iOS" */;
buildPhases = ( buildPhases = (
865C8403EF02C089369A9FCB /* Sources */, 865C8403EF02C089369A9FCB /* Sources */,
C5E027A72ECCDFE200BD6012 /* Resources */,
); );
buildRules = ( buildRules = (
); );
@@ -281,6 +308,7 @@
4EB6BA1B8464F1EA38F4E286 /* P256K */, 4EB6BA1B8464F1EA38F4E286 /* P256K */,
A6E3E56F2E77036A0032EA8A /* BitLogger */, A6E3E56F2E77036A0032EA8A /* BitLogger */,
A6E3EA7E2E7706720032EA8A /* Tor */, A6E3EA7E2E7706720032EA8A /* Tor */,
A6BCF9472F80953E001CF9B9 /* BitFoundation */,
); );
productName = bitchat_iOS; productName = bitchat_iOS;
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */; productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
@@ -312,6 +340,7 @@
ne, ne,
"pt-BR", "pt-BR",
ru, ru,
tr,
uk, uk,
"zh-Hans", "zh-Hans",
); );
@@ -320,7 +349,8 @@
packageReferences = ( packageReferences = (
B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */, B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */,
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */, A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */, A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */,
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */,
); );
preferredProjectObjectVersion = 90; preferredProjectObjectVersion = 90;
projectDirPath = ""; projectDirPath = "";
@@ -342,6 +372,16 @@
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */, E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */,
); );
}; };
C5E027A42ECCDFD700BD6012 /* Resources */ = {
isa = PBXResourcesBuildPhase;
files = (
);
};
C5E027A72ECCDFE200BD6012 /* Resources */ = {
isa = PBXResourcesBuildPhase;
files = (
);
};
CD6E8F32BC38357473954F97 /* Resources */ = { CD6E8F32BC38357473954F97 /* Resources */ = {
isa = PBXResourcesBuildPhase; isa = PBXResourcesBuildPhase;
files = ( files = (
@@ -515,6 +555,7 @@
CODE_SIGNING_REQUIRED = YES; CODE_SIGNING_REQUIRED = YES;
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements; CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = NO; ENABLE_PREVIEWS = NO;
INFOPLIST_FILE = bitchat/Info.plist; INFOPLIST_FILE = bitchat/Info.plist;
@@ -525,7 +566,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = "$(MARKETING_VERSION)"; MARKETING_VERSION = 1.5.1;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -575,6 +616,7 @@
CODE_SIGNING_REQUIRED = YES; CODE_SIGNING_REQUIRED = YES;
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements; CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
INFOPLIST_FILE = bitchat/Info.plist; INFOPLIST_FILE = bitchat/Info.plist;
@@ -585,7 +627,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = "$(MARKETING_VERSION)"; MARKETING_VERSION = 1.5.1;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -621,7 +663,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
MARKETING_VERSION = "$(MARKETING_VERSION)"; MARKETING_VERSION = 1.5.1;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -713,7 +755,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
MARKETING_VERSION = "$(MARKETING_VERSION)"; MARKETING_VERSION = 1.5.1;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -874,13 +916,17 @@
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */ /* Begin XCLocalSwiftPackageReference section */
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = localPackages/BitFoundation;
};
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */ = { A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */ = {
isa = XCLocalSwiftPackageReference; isa = XCLocalSwiftPackageReference;
relativePath = localPackages/BitLogger; relativePath = localPackages/BitLogger;
}; };
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */ = { A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */ = {
isa = XCLocalSwiftPackageReference; isa = XCLocalSwiftPackageReference;
relativePath = localPackages/Tor; relativePath = localPackages/Arti;
}; };
/* End XCLocalSwiftPackageReference section */ /* End XCLocalSwiftPackageReference section */
@@ -889,8 +935,8 @@
isa = XCRemoteSwiftPackageReference; isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/21-DOT-DEV/swift-secp256k1"; repositoryURL = "https://github.com/21-DOT-DEV/swift-secp256k1";
requirement = { requirement = {
kind = upToNextMajorVersion; kind = exactVersion;
minimumVersion = 0.21.1; version = 0.21.1;
}; };
}; };
/* End XCRemoteSwiftPackageReference section */ /* End XCRemoteSwiftPackageReference section */
@@ -901,6 +947,15 @@
package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */; package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */;
productName = P256K; productName = P256K;
}; };
A6BCF9472F80953E001CF9B9 /* BitFoundation */ = {
isa = XCSwiftPackageProductDependency;
productName = BitFoundation;
};
A6BCF9492F809550001CF9B9 /* BitFoundation */ = {
isa = XCSwiftPackageProductDependency;
package = A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */;
productName = BitFoundation;
};
A6E3E56F2E77036A0032EA8A /* BitLogger */ = { A6E3E56F2E77036A0032EA8A /* BitLogger */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
productName = BitLogger; productName = BitLogger;
@@ -98,8 +98,8 @@
</BuildableProductRunnable> </BuildableProductRunnable>
<EnvironmentVariables> <EnvironmentVariables>
<EnvironmentVariable <EnvironmentVariable
key = "-DBITCHAT_DEV_ALLOW_CLEARNET" key = "BITCHAT_LOG_LEVEL"
value = "" value = "debug"
isEnabled = "YES"> isEnabled = "YES">
</EnvironmentVariable> </EnvironmentVariable>
</EnvironmentVariables> </EnvironmentVariables>
+363
View File
@@ -0,0 +1,363 @@
import BitFoundation
import Combine
import Foundation
enum SharedContentKind: String, Sendable, Equatable {
case text
case url
}
enum RuntimeScenePhase: String, Sendable, Equatable {
case active
case inactive
case background
}
enum TorLifecycleEvent: String, Sendable, Equatable {
case willStart
case willRestart
case didBecomeReady
case preferenceChanged
}
enum AppEvent: Sendable, Equatable {
case launched
case startupCompleted
case scenePhaseChanged(RuntimeScenePhase)
case openedURL(String)
case sharedContentAccepted(SharedContentKind)
case notificationOpened(peerID: PeerID?)
case deepLinkOpened(String)
case torLifecycleChanged(TorLifecycleEvent)
case nostrRelayConnectionChanged(Bool)
case terminationRequested
}
actor AppEventStream {
private var continuations: [UUID: AsyncStream<AppEvent>.Continuation] = [:]
func stream() -> AsyncStream<AppEvent> {
let id = UUID()
return AsyncStream { continuation in
continuations[id] = continuation
continuation.onTermination = { [id] _ in
Task {
await self.removeContinuation(id)
}
}
}
}
func emit(_ event: AppEvent) {
for continuation in continuations.values {
continuation.yield(event)
}
}
func finish() {
for continuation in continuations.values {
continuation.finish()
}
continuations.removeAll()
}
private func removeContinuation(_ id: UUID) {
continuations.removeValue(forKey: id)
}
}
struct PeerHandle: Sendable, Identifiable {
let id: String
let routingPeerID: PeerID
let displayName: String?
let noisePublicKeyHex: String?
let nostrPublicKey: String?
}
extension PeerHandle: Equatable {
static func == (lhs: PeerHandle, rhs: PeerHandle) -> Bool {
lhs.id == rhs.id
}
}
extension PeerHandle: Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
enum ConversationID: Hashable, Sendable {
case mesh
case geohash(String)
case direct(PeerHandle)
init(channelID: ChannelID) {
switch channelID {
case .mesh:
self = .mesh
case .location(let channel):
self = .geohash(channel.geohash.lowercased())
}
}
}
@MainActor
final class IdentityResolver {
private var handlesByRoutingPeerID: [PeerID: PeerHandle] = [:]
private var handlesByNoiseKey: [String: PeerHandle] = [:]
private var handlesByNostrKey: [String: PeerHandle] = [:]
func register(peers: [BitchatPeer]) {
for peer in peers {
_ = register(peer: peer)
}
}
@discardableResult
func register(peer: BitchatPeer) -> PeerHandle {
let handle = buildHandle(
routingPeerID: peer.peerID,
displayName: peer.displayName,
noisePublicKeyHex: peer.noisePublicKey.isEmpty ? nil : peer.noisePublicKey.hexEncodedString().lowercased(),
nostrPublicKey: normalizedNostrKey(peer.nostrPublicKey)
)
cache(handle)
return handle
}
func canonicalHandle(for peerID: PeerID, displayName: String? = nil) -> PeerHandle {
if let handle = handlesByRoutingPeerID[peerID] {
return handle
}
if peerID.isNoiseKeyHex, let handle = handlesByNoiseKey[peerID.bare] {
return handle
}
if (peerID.isGeoDM || peerID.isGeoChat), let handle = handlesByNostrKey[peerID.bare] {
return handle
}
let handle = buildHandle(
routingPeerID: peerID,
displayName: displayName,
noisePublicKeyHex: peerID.isNoiseKeyHex ? peerID.bare : nil,
nostrPublicKey: (peerID.isGeoDM || peerID.isGeoChat) ? peerID.bare : nil
)
cache(handle)
return handle
}
private func buildHandle(
routingPeerID: PeerID,
displayName: String?,
noisePublicKeyHex: String?,
nostrPublicKey: String?
) -> PeerHandle {
let canonicalID: String
if let noisePublicKeyHex {
canonicalID = "noise:\(noisePublicKeyHex)"
} else if let nostrPublicKey {
canonicalID = "nostr:\(nostrPublicKey)"
} else {
canonicalID = "mesh:\(routingPeerID.id)"
}
let normalizedDisplayName: String?
if let displayName, !displayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
normalizedDisplayName = displayName
} else {
normalizedDisplayName = nil
}
return PeerHandle(
id: canonicalID,
routingPeerID: routingPeerID,
displayName: normalizedDisplayName,
noisePublicKeyHex: noisePublicKeyHex,
nostrPublicKey: nostrPublicKey
)
}
private func normalizedNostrKey(_ nostrPublicKey: String?) -> String? {
guard let nostrPublicKey else { return nil }
let trimmed = nostrPublicKey.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return trimmed.isEmpty ? nil : trimmed
}
private func cache(_ handle: PeerHandle) {
handlesByRoutingPeerID[handle.routingPeerID] = handle
if let noisePublicKeyHex = handle.noisePublicKeyHex {
handlesByNoiseKey[noisePublicKeyHex] = handle
}
if let nostrPublicKey = handle.nostrPublicKey {
handlesByNostrKey[nostrPublicKey] = handle
}
}
}
@MainActor
final class ConversationStore: ObservableObject {
@Published private(set) var activeChannel: ChannelID = .mesh
@Published private(set) var selectedPrivatePeerID: PeerID?
@Published private(set) var selectedConversationID: ConversationID = .mesh
@Published private(set) var unreadConversations: Set<ConversationID> = []
@Published private(set) var messagesByConversation: [ConversationID: [BitchatMessage]] = [:]
private var directHandlesByConversation: [ConversationID: PeerHandle] = [:]
func setActiveChannel(_ channelID: ChannelID) {
activeChannel = channelID
if selectedPrivatePeerID == nil {
selectedConversationID = ConversationID(channelID: channelID)
}
}
func setSelectedPeerID(
_ peerID: PeerID?,
activeChannel: ChannelID,
identityResolver: IdentityResolver
) {
self.activeChannel = activeChannel
selectedPrivatePeerID = peerID
if let peerID {
selectedConversationID = directConversationID(
for: peerID,
identityResolver: identityResolver
)
} else {
selectedConversationID = ConversationID(channelID: activeChannel)
}
}
func replaceMessages(_ messages: [BitchatMessage], for conversationID: ConversationID) {
messagesByConversation[conversationID] = normalized(messages)
}
func replaceMessages(_ messages: [BitchatMessage], for channelID: ChannelID) {
replaceMessages(messages, for: ConversationID(channelID: channelID))
}
func synchronizePublicConversation(_ messages: [BitchatMessage], activeChannel: ChannelID) {
setActiveChannel(activeChannel)
replaceMessages(messages, for: activeChannel)
}
func messages(for conversationID: ConversationID) -> [BitchatMessage] {
messagesByConversation[conversationID] ?? []
}
func directMessages(
for peerID: PeerID,
identityResolver: IdentityResolver
) -> [BitchatMessage] {
messages(for: directConversationID(for: peerID, identityResolver: identityResolver))
}
func directMessagesByPeerID() -> [PeerID: [BitchatMessage]] {
var messagesByPeerID: [PeerID: [BitchatMessage]] = [:]
for (conversationID, handle) in directHandlesByConversation {
messagesByPeerID[handle.routingPeerID] = messages(for: conversationID)
}
return messagesByPeerID
}
func unreadDirectPeerIDs() -> Set<PeerID> {
unreadConversations.reduce(into: Set<PeerID>()) { result, conversationID in
guard case .direct(let handle) = conversationID else { return }
result.insert(directHandlesByConversation[conversationID]?.routingPeerID ?? handle.routingPeerID)
}
}
func synchronizeSelection(
activeChannel: ChannelID,
selectedPeerID: PeerID?,
identityResolver: IdentityResolver
) {
setSelectedPeerID(
selectedPeerID,
activeChannel: activeChannel,
identityResolver: identityResolver
)
}
func synchronizePrivateChats(
_ privateChats: [PeerID: [BitchatMessage]],
unreadPeerIDs: Set<PeerID>,
identityResolver: IdentityResolver
) {
var liveConversations = Set<ConversationID>()
for (peerID, messages) in privateChats {
let handle = identityResolver.canonicalHandle(for: peerID, displayName: messages.last?.sender)
let conversationID = ConversationID.direct(handle)
liveConversations.insert(conversationID)
directHandlesByConversation[conversationID] = handle
messagesByConversation[conversationID] = normalized(messages)
}
let staleDirectConversations = messagesByConversation.keys.filter { conversationID in
guard case .direct = conversationID else { return false }
return !liveConversations.contains(conversationID)
}
for conversationID in staleDirectConversations {
messagesByConversation.removeValue(forKey: conversationID)
unreadConversations.remove(conversationID)
directHandlesByConversation.removeValue(forKey: conversationID)
}
let publicUnread = unreadConversations.filter { conversationID in
switch conversationID {
case .mesh, .geohash:
return true
case .direct:
return false
}
}
unreadConversations = unreadPeerIDs.reduce(into: publicUnread) { result, peerID in
let handle = identityResolver.canonicalHandle(for: peerID)
result.insert(.direct(handle))
}
}
func markRead(_ conversationID: ConversationID) {
unreadConversations.remove(conversationID)
}
func markRead(
peerID: PeerID,
identityResolver: IdentityResolver
) {
markRead(directConversationID(for: peerID, identityResolver: identityResolver))
}
private func normalized(_ messages: [BitchatMessage]) -> [BitchatMessage] {
var uniqueMessages: [String: BitchatMessage] = [:]
for message in messages {
uniqueMessages[message.id] = message
}
return uniqueMessages.values.sorted { lhs, rhs in
if lhs.timestamp != rhs.timestamp {
return lhs.timestamp < rhs.timestamp
}
return lhs.id < rhs.id
}
}
private func directConversationID(
for peerID: PeerID,
identityResolver: IdentityResolver
) -> ConversationID {
let handle = identityResolver.canonicalHandle(for: peerID)
let conversationID = ConversationID.direct(handle)
directHandlesByConversation[conversationID] = handle
return conversationID
}
}
+100
View File
@@ -0,0 +1,100 @@
import BitFoundation
import Combine
import CoreBluetooth
import Foundation
@MainActor
final class AppChromeModel: ObservableObject {
@Published private(set) var hasUnreadPrivateMessages = false
@Published var nickname: String
@Published var showingFingerprintFor: PeerID?
@Published var isAppInfoPresented = false
@Published var isLocationChannelsSheetPresented = false
@Published var showBluetoothAlert = false
@Published var bluetoothAlertMessage = ""
@Published var bluetoothState: CBManagerState = .unknown
@Published var showScreenshotPrivacyWarning = false
private let chatViewModel: ChatViewModel
private var cancellables = Set<AnyCancellable>()
init(chatViewModel: ChatViewModel, privateInboxModel: PrivateInboxModel) {
self.chatViewModel = chatViewModel
self.nickname = chatViewModel.nickname
bind(privateInboxModel: privateInboxModel)
}
var shouldSuppressScreenshotNotification: Bool {
isLocationChannelsSheetPresented || isAppInfoPresented
}
func setNickname(_ nickname: String) {
self.nickname = nickname
if chatViewModel.nickname != nickname {
chatViewModel.nickname = nickname
}
}
func validateAndSaveNickname() {
chatViewModel.validateAndSaveNickname()
if nickname != chatViewModel.nickname {
nickname = chatViewModel.nickname
}
}
func openMostRelevantPrivateChat() {
chatViewModel.openMostRelevantPrivateChat()
}
func showFingerprint(for peerID: PeerID) {
showingFingerprintFor = peerID
}
func clearFingerprint() {
showingFingerprintFor = nil
}
func presentAppInfo() {
isAppInfoPresented = true
}
func triggerScreenshotPrivacyWarning() {
showScreenshotPrivacyWarning = true
}
func panicClearAllData() {
chatViewModel.panicClearAllData()
}
private func bind(privateInboxModel: PrivateInboxModel) {
privateInboxModel.$unreadPeerIDs
.receive(on: DispatchQueue.main)
.sink { [weak self] unreadPeerIDs in
self?.hasUnreadPrivateMessages = !unreadPeerIDs.isEmpty
}
.store(in: &cancellables)
chatViewModel.$nickname
.receive(on: DispatchQueue.main)
.sink { [weak self] nickname in
guard let self, self.nickname != nickname else { return }
self.nickname = nickname
}
.store(in: &cancellables)
chatViewModel.$showBluetoothAlert
.receive(on: DispatchQueue.main)
.assign(to: &$showBluetoothAlert)
chatViewModel.$bluetoothAlertMessage
.receive(on: DispatchQueue.main)
.assign(to: &$bluetoothAlertMessage)
chatViewModel.$bluetoothState
.receive(on: DispatchQueue.main)
.assign(to: &$bluetoothState)
hasUnreadPrivateMessages = !privateInboxModel.unreadPeerIDs.isEmpty
}
}
+387
View File
@@ -0,0 +1,387 @@
import BitFoundation
import Combine
import Foundation
import SwiftUI
import Tor
import UserNotifications
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
@MainActor
final class AppRuntime: ObservableObject {
let chatViewModel: ChatViewModel
let events = AppEventStream()
let conversationStore: ConversationStore
let peerIdentityStore: PeerIdentityStore
let locationPresenceStore: LocationPresenceStore
let publicChatModel: PublicChatModel
let privateInboxModel: PrivateInboxModel
let privateConversationModel: PrivateConversationModel
let verificationModel: VerificationModel
let conversationUIModel: ConversationUIModel
let locationChannelsModel: LocationChannelsModel
let peerListModel: PeerListModel
let appChromeModel: AppChromeModel
private let idBridge: NostrIdentityBridge
private var cancellables = Set<AnyCancellable>()
private var started = false
private var lastNostrRelayConnectedState = false
private var didHandleInitialNostrConnection = false
#if os(iOS)
private var didHandleInitialActive = false
private var didEnterBackground = false
#endif
init(
keychain: KeychainManagerProtocol = KeychainManager(),
idBridge: NostrIdentityBridge = NostrIdentityBridge()
) {
self.idBridge = idBridge
let identityResolver = IdentityResolver()
let conversationStore = ConversationStore()
let peerIdentityStore = PeerIdentityStore()
let locationPresenceStore = LocationPresenceStore()
let locationManager = LocationChannelManager.shared
self.conversationStore = conversationStore
self.peerIdentityStore = peerIdentityStore
self.locationPresenceStore = locationPresenceStore
self.chatViewModel = ChatViewModel(
keychain: keychain,
idBridge: idBridge,
identityManager: SecureIdentityStateManager(keychain),
conversationStore: conversationStore,
identityResolver: identityResolver,
peerIdentityStore: peerIdentityStore,
locationPresenceStore: locationPresenceStore,
locationManager: locationManager
)
self.publicChatModel = PublicChatModel(conversationStore: conversationStore)
self.privateInboxModel = PrivateInboxModel(conversationStore: conversationStore)
self.locationChannelsModel = LocationChannelsModel(manager: locationManager)
self.privateConversationModel = PrivateConversationModel(
chatViewModel: self.chatViewModel,
conversationStore: conversationStore,
locationChannelsModel: self.locationChannelsModel,
peerIdentityStore: peerIdentityStore
)
self.verificationModel = VerificationModel(
chatViewModel: self.chatViewModel,
privateConversationModel: self.privateConversationModel,
peerIdentityStore: peerIdentityStore
)
self.conversationUIModel = ConversationUIModel(
chatViewModel: self.chatViewModel,
privateConversationModel: self.privateConversationModel,
conversationStore: conversationStore
)
self.peerListModel = PeerListModel(
chatViewModel: self.chatViewModel,
conversationStore: conversationStore,
locationChannelsModel: self.locationChannelsModel,
peerIdentityStore: peerIdentityStore,
locationPresenceStore: locationPresenceStore
)
self.appChromeModel = AppChromeModel(
chatViewModel: self.chatViewModel,
privateInboxModel: self.privateInboxModel
)
GeoRelayDirectory.shared.prefetchIfNeeded()
bindRuntimeObservers()
NotificationDelegate.shared.runtime = self
}
func start() {
guard !started else {
checkForSharedContent()
return
}
started = true
NotificationDelegate.shared.runtime = self
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
announceInitialTorStatusIfNeeded()
Task(priority: .utility) { [weak self] in
guard let self else { return }
let nickname = await MainActor.run { self.chatViewModel.nickname }
let npub = await MainActor.run {
try? self.idBridge.getCurrentNostrIdentity()?.npub
}
await MainActor.run {
_ = VerificationService.shared.buildMyQRString(nickname: nickname, npub: npub)
}
}
NetworkActivationService.shared.start()
GeohashPresenceService.shared.start()
checkForSharedContent()
record(.launched)
record(.startupCompleted)
}
func handleOpenURL(_ url: URL) {
record(.openedURL(url.absoluteString))
if url.scheme == "bitchat", url.host == "share" {
checkForSharedContent()
}
}
func handleDidBecomeActiveNotification() {
chatViewModel.handleDidBecomeActive()
checkForSharedContent()
}
#if os(macOS)
func handleMacDidBecomeActiveNotification() {
record(.scenePhaseChanged(.active))
chatViewModel.handleDidBecomeActive()
checkForSharedContent()
}
#endif
#if os(iOS)
func handleScenePhaseChange(_ newPhase: ScenePhase) {
switch newPhase {
case .background:
record(.scenePhaseChanged(.background))
TorManager.shared.setAppForeground(false)
TorManager.shared.goDormantOnBackground()
chatViewModel.endGeohashSampling()
NostrRelayManager.shared.disconnect()
didEnterBackground = true
case .active:
record(.scenePhaseChanged(.active))
chatViewModel.meshService.startServices()
TorManager.shared.setAppForeground(true)
let shouldRefreshNostrConnections = didHandleInitialActive && didEnterBackground
if didHandleInitialActive && didEnterBackground {
if TorManager.shared.isAutoStartAllowed() && !TorManager.shared.isReady {
TorManager.shared.ensureRunningOnForeground()
}
} else {
didHandleInitialActive = true
}
didEnterBackground = false
if shouldRefreshNostrConnections && TorManager.shared.isAutoStartAllowed() {
Task.detached {
let _ = await TorManager.shared.awaitReady(timeout: 60)
await MainActor.run {
TorURLSession.shared.rebuild()
NostrRelayManager.shared.resetAllConnections()
}
}
}
chatViewModel.handleDidBecomeActive()
checkForSharedContent()
case .inactive:
record(.scenePhaseChanged(.inactive))
@unknown default:
break
}
}
#endif
func applicationWillTerminate() {
record(.terminationRequested)
chatViewModel.applicationWillTerminate()
}
func handleNotificationResponse(identifier: String, userInfo: [AnyHashable: Any]) {
if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) {
record(.notificationOpened(peerID: peerID))
chatViewModel.startPrivateChat(with: peerID)
}
if let deepLink = userInfo["deeplink"] as? String, let url = URL(string: deepLink) {
record(.deepLinkOpened(deepLink))
openExternalURL(url)
}
}
func presentationOptions(
forNotificationIdentifier identifier: String,
userInfo: [AnyHashable: Any]
) async -> UNNotificationPresentationOptions {
if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) {
if conversationStore.selectedPrivatePeerID == peerID {
return []
}
return [.banner, .sound]
}
if identifier.hasPrefix("geo-activity-"),
let deepLink = userInfo["deeplink"] as? String,
let geohash = deepLink.components(separatedBy: "/").last,
case .location(let channel) = locationChannelsModel.selectedChannel,
channel.geohash == geohash {
return []
}
return [.banner, .sound]
}
}
private extension AppRuntime {
func bindRuntimeObservers() {
NostrRelayManager.shared.$isConnected
.receive(on: DispatchQueue.main)
.sink { [weak self] isConnected in
self?.handleNostrRelayConnectionChanged(isConnected)
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .TorWillRestart)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.record(.torLifecycleChanged(.willRestart))
self?.chatViewModel.handleTorWillRestart()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .TorDidBecomeReady)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.record(.torLifecycleChanged(.didBecomeReady))
self?.chatViewModel.handleTorDidBecomeReady()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .TorWillStart)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.record(.torLifecycleChanged(.willStart))
self?.chatViewModel.handleTorWillStart()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .TorUserPreferenceChanged)
.receive(on: DispatchQueue.main)
.sink { [weak self] notification in
self?.record(.torLifecycleChanged(.preferenceChanged))
self?.chatViewModel.handleTorPreferenceChanged(notification)
}
.store(in: &cancellables)
#if os(iOS)
NotificationCenter.default.publisher(for: UIApplication.userDidTakeScreenshotNotification)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.handleScreenshotCaptured()
}
.store(in: &cancellables)
#endif
}
func checkForSharedContent() {
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID),
let sharedContent = userDefaults.string(forKey: "sharedContent"),
let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else {
return
}
guard Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds else {
return
}
let contentKind = SharedContentKind(rawValue: userDefaults.string(forKey: "sharedContentType") ?? "") ?? .text
userDefaults.removeObject(forKey: "sharedContent")
userDefaults.removeObject(forKey: "sharedContentType")
userDefaults.removeObject(forKey: "sharedContentDate")
switch contentKind {
case .url:
if let data = sharedContent.data(using: .utf8),
let urlData = try? JSONSerialization.jsonObject(with: data) as? [String: String],
let url = urlData["url"] {
chatViewModel.sendMessage(url)
} else {
chatViewModel.sendMessage(sharedContent)
}
case .text:
chatViewModel.sendMessage(sharedContent)
}
record(.sharedContentAccepted(contentKind))
}
func handleNostrRelayConnectionChanged(_ isConnected: Bool) {
record(.nostrRelayConnectionChanged(isConnected))
let becameConnected = isConnected && !lastNostrRelayConnectedState
lastNostrRelayConnectedState = isConnected
guard started, becameConnected else { return }
let isInitialConnection = !didHandleInitialNostrConnection
didHandleInitialNostrConnection = true
if !chatViewModel.nostrHandlersSetup {
chatViewModel.setupNostrMessageHandling()
chatViewModel.nostrHandlersSetup = true
}
guard !isInitialConnection else { return }
chatViewModel.resubscribeCurrentGeohash()
chatViewModel.geoChannelCoordinator?.refreshSampling()
}
func announceInitialTorStatusIfNeeded() {
if TorManager.shared.torEnforced &&
!chatViewModel.torStatusAnnounced &&
TorManager.shared.isAutoStartAllowed() {
chatViewModel.torStatusAnnounced = true
chatViewModel.addGeohashOnlySystemMessage(
String(localized: "system.tor.starting", comment: "System message when Tor is starting")
)
} else if !TorManager.shared.torEnforced && !chatViewModel.torStatusAnnounced {
chatViewModel.torStatusAnnounced = true
chatViewModel.addGeohashOnlySystemMessage(
String(localized: "system.tor.dev_bypass", comment: "System message when Tor bypass is enabled in development")
)
}
}
func handleScreenshotCaptured() {
if appChromeModel.isLocationChannelsSheetPresented {
appChromeModel.triggerScreenshotPrivacyWarning()
return
}
if appChromeModel.isAppInfoPresented {
return
}
chatViewModel.handleScreenshotCaptured()
}
func openExternalURL(_ url: URL) {
#if os(iOS)
UIApplication.shared.open(url)
#else
NSWorkspace.shared.open(url)
#endif
}
func record(_ event: AppEvent) {
Task {
await events.emit(event)
}
}
}
+183
View File
@@ -0,0 +1,183 @@
import BitFoundation
import Combine
import SwiftUI
#if os(iOS)
import UIKit
#endif
@MainActor
final class ConversationUIModel: ObservableObject {
@Published private(set) var showAutocomplete = false
@Published private(set) var autocompleteSuggestions: [String] = []
@Published private(set) var currentNickname: String
@Published private(set) var isBatchingPublic = false
@Published private(set) var canSendMediaInCurrentContext = true
private let chatViewModel: ChatViewModel
private let privateConversationModel: PrivateConversationModel
private let conversationStore: ConversationStore
private var activeChannel: ChannelID
private var cancellables = Set<AnyCancellable>()
init(
chatViewModel: ChatViewModel,
privateConversationModel: PrivateConversationModel,
conversationStore: ConversationStore
) {
self.chatViewModel = chatViewModel
self.privateConversationModel = privateConversationModel
self.conversationStore = conversationStore
self.activeChannel = conversationStore.activeChannel
self.currentNickname = chatViewModel.nickname
self.isBatchingPublic = chatViewModel.isBatchingPublic
self.showAutocomplete = chatViewModel.showAutocomplete
self.autocompleteSuggestions = chatViewModel.autocompleteSuggestions
self.canSendMediaInCurrentContext = chatViewModel.canSendMediaInCurrentContext
bind()
}
func setCurrentColorScheme(_ colorScheme: ColorScheme) {
chatViewModel.currentColorScheme = colorScheme
}
func sendMessage(_ message: String) {
chatViewModel.sendMessage(message)
}
func clearCurrentConversation() {
chatViewModel.sendMessage("/clear")
}
func sendHug(to sender: String) {
chatViewModel.sendMessage("/hug @\(sender)")
}
func sendSlap(to sender: String) {
chatViewModel.sendMessage("/slap @\(sender)")
}
func block(peerID: PeerID?, displayName: String?) {
guard let displayName else { return }
if let peerID, peerID.isGeoChat,
let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) {
chatViewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: displayName)
} else {
chatViewModel.sendMessage("/block \(displayName)")
}
}
func updateAutocomplete(for text: String, cursorPosition: Int) {
chatViewModel.updateAutocomplete(for: text, cursorPosition: cursorPosition)
}
func completeNickname(_ nickname: String, in text: inout String) -> Int {
chatViewModel.completeNickname(nickname, in: &text)
}
func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
chatViewModel.formatMessageAsText(message, colorScheme: colorScheme)
}
func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
chatViewModel.formatMessageHeader(message, colorScheme: colorScheme)
}
func mediaAttachment(for message: BitchatMessage) -> BitchatMessage.Media? {
message.mediaAttachment(for: currentNickname)
}
func isSelfSender(peerID: PeerID?, displayName: String?) -> Bool {
chatViewModel.isSelfSender(peerID: peerID, displayName: displayName)
}
func isSentByCurrentUser(_ message: BitchatMessage) -> Bool {
message.sender == currentNickname || message.sender.hasPrefix(currentNickname + "#")
}
func isMediaMessageFromCurrentUser(_ message: BitchatMessage) -> Bool {
message.sender == currentNickname || message.senderPeerID == chatViewModel.meshService.myPeerID
}
func senderDisplayName(for peerID: PeerID, fallbackMessages: [BitchatMessage]) -> String? {
if peerID.isGeoDM || peerID.isGeoChat {
return chatViewModel.geohashDisplayName(for: peerID)
}
if let nickname = chatViewModel.meshService.peerNickname(peerID: peerID) {
return nickname
}
return fallbackMessages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender
}
#if os(iOS)
func processSelectedImage(_ image: UIImage?) {
chatViewModel.processThenSendImage(image)
}
#endif
func processSelectedImage(from url: URL?) {
#if os(macOS)
chatViewModel.processThenSendImage(from: url)
#endif
}
func sendVoiceNote(at url: URL) {
chatViewModel.sendVoiceNote(at: url)
}
func cancelMediaSend(messageID: String) {
chatViewModel.cancelMediaSend(messageID: messageID)
}
func deleteMediaMessage(messageID: String) {
chatViewModel.deleteMediaMessage(messageID: messageID)
}
private func bind() {
chatViewModel.$nickname
.receive(on: DispatchQueue.main)
.assign(to: &$currentNickname)
chatViewModel.$showAutocomplete
.receive(on: DispatchQueue.main)
.assign(to: &$showAutocomplete)
chatViewModel.$autocompleteSuggestions
.receive(on: DispatchQueue.main)
.assign(to: &$autocompleteSuggestions)
chatViewModel.$isBatchingPublic
.receive(on: DispatchQueue.main)
.assign(to: &$isBatchingPublic)
conversationStore.$activeChannel
.receive(on: DispatchQueue.main)
.sink { [weak self] channel in
self?.activeChannel = channel
self?.refreshComputedState()
}
.store(in: &cancellables)
privateConversationModel.$selectedPeerID
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshComputedState()
}
.store(in: &cancellables)
}
private func refreshComputedState() {
if let selectedPeerID = privateConversationModel.selectedPeerID {
canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat)
return
}
switch activeChannel {
case .mesh:
canSendMediaInCurrentContext = true
case .location:
canSendMediaInCurrentContext = false
}
}
}
+176
View File
@@ -0,0 +1,176 @@
import BitFoundation
import Combine
import Foundation
@MainActor
final class LocationChannelsModel: ObservableObject {
@Published private(set) var permissionState: LocationChannelManager.PermissionState
@Published private(set) var availableChannels: [GeohashChannel]
@Published private(set) var selectedChannel: ChannelID
@Published private(set) var teleported: Bool
@Published private(set) var bookmarks: [String]
@Published private(set) var bookmarkNames: [String: String]
@Published private(set) var locationNames: [GeohashChannelLevel: String]
@Published private(set) var userTorEnabled: Bool
private let manager: LocationChannelManager
private let network: NetworkActivationService
private var cancellables = Set<AnyCancellable>()
init(
manager: LocationChannelManager? = nil,
network: NetworkActivationService? = nil
) {
let manager = manager ?? .shared
let network = network ?? .shared
self.manager = manager
self.network = network
self.permissionState = manager.permissionState
self.availableChannels = manager.availableChannels
self.selectedChannel = manager.selectedChannel
self.teleported = manager.teleported
self.bookmarks = manager.bookmarks
self.bookmarkNames = manager.bookmarkNames
self.locationNames = manager.locationNames
self.userTorEnabled = network.userTorEnabled
bind()
}
var currentBuildingGeohash: String? {
availableChannels.first(where: { $0.level == .building })?.geohash
}
func isSelected(_ channel: GeohashChannel) -> Bool {
guard case .location(let selected) = selectedChannel else { return false }
return selected == channel
}
func isBookmarked(_ geohash: String) -> Bool {
manager.isBookmarked(geohash)
}
func enableLocationChannels() {
manager.enableLocationChannels()
}
func refreshChannels() {
manager.refreshChannels()
}
func enableAndRefresh() {
manager.enableLocationChannels()
manager.refreshChannels()
}
func beginLiveRefresh() {
manager.beginLiveRefresh()
}
func endLiveRefresh() {
manager.endLiveRefresh()
}
func select(_ channel: ChannelID) {
manager.select(channel)
}
func markTeleported(for geohash: String, _ flag: Bool) {
manager.markTeleported(for: geohash, flag)
}
func toggleBookmark(_ geohash: String) {
manager.toggleBookmark(geohash)
}
func resolveBookmarkNameIfNeeded(for geohash: String) {
manager.resolveBookmarkNameIfNeeded(for: geohash)
}
func locationName(for level: GeohashChannelLevel) -> String? {
locationNames[level]
}
func setUserTorEnabled(_ enabled: Bool) {
network.setUserTorEnabled(enabled)
}
func refreshMeshChannelsIfNeeded() {
guard case .mesh = selectedChannel,
permissionState == .authorized,
availableChannels.isEmpty else {
return
}
refreshChannels()
}
func openLocationChannel(for geohash: String) {
let normalized = geohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
guard (2...12).contains(normalized.count),
normalized.allSatisfy({ allowed.contains($0) }) else {
return
}
let channel = GeohashChannel(level: level(forLength: normalized.count), geohash: normalized)
let isRegional = availableChannels.contains { $0.geohash == normalized }
if !isRegional && !availableChannels.isEmpty {
markTeleported(for: normalized, true)
}
select(.location(channel))
}
func teleport(to geohash: String) {
let normalized = geohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let channel = GeohashChannel(level: level(forLength: normalized.count), geohash: normalized)
markTeleported(for: normalized, true)
select(.location(channel))
}
private func bind() {
manager.$permissionState
.receive(on: DispatchQueue.main)
.assign(to: &$permissionState)
manager.$availableChannels
.receive(on: DispatchQueue.main)
.assign(to: &$availableChannels)
manager.$selectedChannel
.receive(on: DispatchQueue.main)
.assign(to: &$selectedChannel)
manager.$teleported
.receive(on: DispatchQueue.main)
.assign(to: &$teleported)
manager.$bookmarks
.receive(on: DispatchQueue.main)
.assign(to: &$bookmarks)
manager.$bookmarkNames
.receive(on: DispatchQueue.main)
.assign(to: &$bookmarkNames)
manager.$locationNames
.receive(on: DispatchQueue.main)
.assign(to: &$locationNames)
network.$userTorEnabled
.receive(on: DispatchQueue.main)
.assign(to: &$userTorEnabled)
}
private func level(forLength length: Int) -> GeohashChannelLevel {
switch length {
case 0...2: return .region
case 3...4: return .province
case 5: return .city
case 6: return .neighborhood
case 7: return .block
case 8...12: return .building
default: return .block
}
}
}
+51
View File
@@ -0,0 +1,51 @@
import Combine
import Foundation
@MainActor
final class LocationPresenceStore: ObservableObject {
@Published private(set) var currentGeohash: String?
@Published private(set) var geoNicknames: [String: String] = [:]
@Published private(set) var teleportedGeo: Set<String> = []
func setCurrentGeohash(_ geohash: String?) {
currentGeohash = geohash?.lowercased()
}
func setNickname(_ nickname: String, for pubkeyHex: String) {
geoNicknames[pubkeyHex.lowercased()] = nickname
}
func replaceGeoNicknames(_ nicknames: [String: String]) {
geoNicknames = Dictionary(
uniqueKeysWithValues: nicknames.map { key, value in
(key.lowercased(), value)
}
)
}
func clearGeoNicknames() {
geoNicknames.removeAll()
}
func markTeleported(_ pubkeyHex: String) {
teleportedGeo.insert(pubkeyHex.lowercased())
}
func clearTeleported(_ pubkeyHex: String) {
teleportedGeo.remove(pubkeyHex.lowercased())
}
func replaceTeleportedGeo(_ pubkeys: Set<String>) {
teleportedGeo = Set(pubkeys.map { $0.lowercased() })
}
func clearTeleportedGeo() {
teleportedGeo.removeAll()
}
func reset() {
currentGeohash = nil
geoNicknames.removeAll()
teleportedGeo.removeAll()
}
}
+125
View File
@@ -0,0 +1,125 @@
import BitFoundation
import Combine
import Foundation
@MainActor
final class PeerIdentityStore: ObservableObject {
@Published private(set) var encryptionStatuses: [PeerID: EncryptionStatus] = [:]
@Published private(set) var verifiedFingerprints: Set<String> = []
private(set) var peerFingerprintsByPeerID: [PeerID: String] = [:]
private(set) var selectedPrivateChatFingerprint: String?
private var stablePeerIDsByShortID: [PeerID: PeerID] = [:]
private var encryptionStatusCache: [PeerID: EncryptionStatus] = [:]
func stablePeerID(forShortID peerID: PeerID) -> PeerID? {
stablePeerIDsByShortID[peerID]
}
func shortPeerID(forStablePeerID stablePeerID: PeerID) -> PeerID? {
stablePeerIDsByShortID.first(where: { $0.value == stablePeerID })?.key
}
func setStablePeerID(_ stablePeerID: PeerID, forShortID peerID: PeerID) {
stablePeerIDsByShortID[peerID] = stablePeerID
}
func replaceStablePeerIDs(_ mappings: [PeerID: PeerID]) {
stablePeerIDsByShortID = mappings
}
func fingerprint(for peerID: PeerID) -> String? {
peerFingerprintsByPeerID[peerID]
}
func setFingerprint(_ fingerprint: String?, for peerID: PeerID) {
if let fingerprint {
peerFingerprintsByPeerID[peerID] = fingerprint
} else {
peerFingerprintsByPeerID.removeValue(forKey: peerID)
}
}
func replaceFingerprintMappings(_ mappings: [PeerID: String]) {
peerFingerprintsByPeerID = mappings
}
@discardableResult
func migrateFingerprintMapping(
from oldPeerID: PeerID,
to newPeerID: PeerID,
fallback: String? = nil
) -> String? {
let fingerprint = peerFingerprintsByPeerID.removeValue(forKey: oldPeerID) ?? fallback
if let fingerprint {
peerFingerprintsByPeerID[newPeerID] = fingerprint
if selectedPrivateChatFingerprint == nil {
selectedPrivateChatFingerprint = fingerprint
}
}
return fingerprint
}
func setSelectedPrivateChatFingerprint(_ fingerprint: String?) {
selectedPrivateChatFingerprint = fingerprint
}
func cachedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus? {
encryptionStatusCache[peerID]
}
func setCachedEncryptionStatus(_ status: EncryptionStatus, for peerID: PeerID) {
encryptionStatusCache[peerID] = status
}
func invalidateEncryptionCache(for peerID: PeerID? = nil) {
if let peerID {
encryptionStatusCache.removeValue(forKey: peerID)
} else {
encryptionStatusCache.removeAll()
}
}
func encryptionStatus(for peerID: PeerID) -> EncryptionStatus? {
encryptionStatuses[peerID]
}
func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) {
if let status {
encryptionStatuses[peerID] = status
} else {
encryptionStatuses.removeValue(forKey: peerID)
}
invalidateEncryptionCache(for: peerID)
}
func replaceEncryptionStatuses(_ statuses: [PeerID: EncryptionStatus]) {
encryptionStatuses = statuses
}
func setVerifiedFingerprints(_ fingerprints: Set<String>) {
verifiedFingerprints = fingerprints
}
func setVerified(_ fingerprint: String, verified: Bool) {
if verified {
verifiedFingerprints.insert(fingerprint)
} else {
verifiedFingerprints.remove(fingerprint)
}
}
func isVerified(_ fingerprint: String) -> Bool {
verifiedFingerprints.contains(fingerprint)
}
func clearAll() {
encryptionStatuses.removeAll()
verifiedFingerprints.removeAll()
peerFingerprintsByPeerID.removeAll()
selectedPrivateChatFingerprint = nil
stablePeerIDsByShortID.removeAll()
encryptionStatusCache.removeAll()
}
}
+260
View File
@@ -0,0 +1,260 @@
import BitFoundation
import Combine
import SwiftUI
struct MeshPeerRow: Identifiable, Equatable {
let peerID: PeerID
let displayName: String
let isMe: Bool
let hasUnread: Bool
let isBlocked: Bool
let isFavorite: Bool
let isConnected: Bool
let isReachable: Bool
let isMutualFavorite: Bool
let encryptionStatus: EncryptionStatus
let showsVerifiedBadgeWhenOffline: Bool
var id: String { peerID.id }
}
struct GeohashPersonRow: Identifiable, Equatable {
let id: String
let displayName: String
let isMe: Bool
let isTeleported: Bool
let isBlocked: Bool
}
@MainActor
final class PeerListModel: ObservableObject {
@Published private(set) var allPeers: [BitchatPeer] = []
@Published private(set) var meshRows: [MeshPeerRow] = []
@Published private(set) var geohashPeople: [GeohashPersonRow] = []
@Published private(set) var reachableMeshPeerCount = 0
@Published private(set) var connectedMeshPeerCount = 0
@Published private(set) var visibleGeohashPeerCount = 0
@Published private(set) var renderID = ""
private let chatViewModel: ChatViewModel
private let conversationStore: ConversationStore
private let locationChannelsModel: LocationChannelsModel
private let peerIdentityStore: PeerIdentityStore
private let locationPresenceStore: LocationPresenceStore
private var cancellables = Set<AnyCancellable>()
init(
chatViewModel: ChatViewModel,
conversationStore: ConversationStore,
locationChannelsModel: LocationChannelsModel? = nil,
peerIdentityStore: PeerIdentityStore? = nil,
locationPresenceStore: LocationPresenceStore? = nil
) {
self.chatViewModel = chatViewModel
self.conversationStore = conversationStore
self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel()
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
self.locationPresenceStore = locationPresenceStore ?? chatViewModel.locationPresenceStore
self.allPeers = chatViewModel.allPeers
bind()
refresh()
}
func colorForMeshPeer(id peerID: PeerID, isDark: Bool) -> Color {
chatViewModel.colorForMeshPeer(id: peerID, isDark: isDark)
}
func colorForGeohashPerson(id: String, isDark: Bool) -> Color {
chatViewModel.colorForNostrPubkey(id, isDark: isDark)
}
func participantCount(for geohash: String) -> Int {
chatViewModel.geohashParticipantCount(for: geohash)
}
func startConversation(with peerID: PeerID) {
chatViewModel.startPrivateChat(with: peerID)
}
func toggleFavorite(peerID: PeerID) {
chatViewModel.toggleFavorite(peerID: peerID)
}
func openGeohashDirectMessage(with pubkeyHex: String) {
chatViewModel.startGeohashDM(withPubkeyHex: pubkeyHex)
}
func blockGeohashUser(pubkeyHexLowercased: String, displayName: String) {
chatViewModel.blockGeohashUser(
pubkeyHexLowercased: pubkeyHexLowercased,
displayName: displayName
)
}
func unblockGeohashUser(pubkeyHexLowercased: String, displayName: String) {
chatViewModel.unblockGeohashUser(
pubkeyHexLowercased: pubkeyHexLowercased,
displayName: displayName
)
}
private func bind() {
chatViewModel.$allPeers
.receive(on: DispatchQueue.main)
.sink { [weak self] peers in
self?.allPeers = peers
self?.refresh()
}
.store(in: &cancellables)
chatViewModel.$nickname
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
locationPresenceStore.$teleportedGeo
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
conversationStore.$unreadConversations
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
peerIdentityStore.$encryptionStatuses
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
peerIdentityStore.$verifiedFingerprints
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
chatViewModel.participantTracker.$visiblePeople
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
locationChannelsModel.$selectedChannel
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
locationChannelsModel.$teleported
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
locationChannelsModel.$availableChannels
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
}
private func refresh() {
let myPeerID = chatViewModel.meshService.myPeerID
let meshRows = allPeers.map { peer in
let isMe = peer.peerID == myPeerID
let verifiedBadge: Bool
if !isMe && !peer.isConnected,
let fingerprint = chatViewModel.getFingerprint(for: peer.peerID) {
verifiedBadge = peerIdentityStore.isVerified(fingerprint)
} else {
verifiedBadge = false
}
return MeshPeerRow(
peerID: peer.peerID,
displayName: isMe ? chatViewModel.nickname : peer.nickname,
isMe: isMe,
hasUnread: chatViewModel.hasUnreadMessages(for: peer.peerID),
isBlocked: !isMe && chatViewModel.isPeerBlocked(peer.peerID),
isFavorite: peer.favoriteStatus?.isFavorite ?? false,
isConnected: peer.isConnected,
isReachable: peer.isReachable,
isMutualFavorite: peer.isMutualFavorite,
encryptionStatus: chatViewModel.getEncryptionStatus(for: peer.peerID),
showsVerifiedBadgeWhenOffline: verifiedBadge
)
}
let meshCounts = meshRows.reduce(into: (reachable: 0, connected: 0)) { counts, row in
guard !row.isMe else { return }
if row.isConnected {
counts.connected += 1
counts.reachable += 1
} else if row.isReachable {
counts.reachable += 1
}
}
let geohashPeople = buildGeohashPeople()
self.meshRows = meshRows
reachableMeshPeerCount = meshCounts.reachable
connectedMeshPeerCount = meshCounts.connected
self.geohashPeople = geohashPeople
visibleGeohashPeerCount = geohashPeople.count
renderID = (
meshRows.map {
"\($0.id)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)"
} +
geohashPeople.map {
"geo:\($0.id)-\($0.isTeleported)-\($0.isBlocked)-\($0.displayName)"
}
).joined(separator: "|")
}
private func buildGeohashPeople() -> [GeohashPersonRow] {
let myHex = currentGeohashIdentityHex()
let teleportedSet = Set(locationPresenceStore.teleportedGeo.map { $0.lowercased() })
return chatViewModel.visibleGeohashPeople().map { person in
let isMe = person.id == myHex
return GeohashPersonRow(
id: person.id,
displayName: person.displayName,
isMe: isMe,
isTeleported: teleportedSet.contains(person.id.lowercased()) || (isMe && locationChannelsModel.teleported),
isBlocked: !isMe && chatViewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id)
)
}
}
private func currentGeohashIdentityHex() -> String? {
guard case .location(let channel) = locationChannelsModel.selectedChannel,
let identity = try? chatViewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) else {
return nil
}
return identity.publicKeyHex.lowercased()
}
}
+298
View File
@@ -0,0 +1,298 @@
import BitFoundation
import Combine
import Foundation
@MainActor
final class PrivateInboxModel: ObservableObject {
@Published private(set) var selectedPeerID: PeerID?
@Published private(set) var unreadPeerIDs: Set<PeerID> = []
@Published private(set) var messagesByPeerID: [PeerID: [BitchatMessage]] = [:]
private let conversationStore: ConversationStore
private var cancellables = Set<AnyCancellable>()
init(conversationStore: ConversationStore) {
self.conversationStore = conversationStore
bind()
refreshMessages()
}
func messages(for peerID: PeerID?) -> [BitchatMessage] {
guard let peerID else { return [] }
return messagesByPeerID[peerID] ?? []
}
private func bind() {
conversationStore.$selectedPrivatePeerID
.receive(on: DispatchQueue.main)
.sink { [weak self] peerID in
self?.selectedPeerID = peerID
self?.refreshMessages()
}
.store(in: &cancellables)
conversationStore.$unreadConversations
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.unreadPeerIDs = self?.conversationStore.unreadDirectPeerIDs() ?? []
self?.refreshMessages()
}
.store(in: &cancellables)
conversationStore.$messagesByConversation
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshMessages()
}
.store(in: &cancellables)
selectedPeerID = conversationStore.selectedPrivatePeerID
unreadPeerIDs = conversationStore.unreadDirectPeerIDs()
}
private func refreshMessages() {
var nextMessagesByPeerID = conversationStore.directMessagesByPeerID()
var peerIDs = Set(nextMessagesByPeerID.keys)
peerIDs.formUnion(conversationStore.unreadDirectPeerIDs())
if let selectedPeerID = conversationStore.selectedPrivatePeerID {
peerIDs.insert(selectedPeerID)
}
for peerID in peerIDs where nextMessagesByPeerID[peerID] == nil {
nextMessagesByPeerID[peerID] = []
}
messagesByPeerID = nextMessagesByPeerID
}
}
enum PrivateConversationAvailability: Equatable {
case bluetoothConnected
case meshReachable
case nostrAvailable
case offline
}
struct PrivateConversationHeaderState: Equatable {
let conversationPeerID: PeerID
let headerPeerID: PeerID
let displayName: String
let availability: PrivateConversationAvailability
let isFavorite: Bool
let encryptionStatus: EncryptionStatus?
var supportsFavoriteToggle: Bool {
!conversationPeerID.isGeoDM
}
}
@MainActor
final class PrivateConversationModel: ObservableObject {
@Published private(set) var selectedPeerID: PeerID?
@Published private(set) var selectedHeaderState: PrivateConversationHeaderState?
private let chatViewModel: ChatViewModel
private let conversationStore: ConversationStore
private let locationChannelsModel: LocationChannelsModel
private let peerIdentityStore: PeerIdentityStore
private var cancellables = Set<AnyCancellable>()
init(
chatViewModel: ChatViewModel,
conversationStore: ConversationStore,
locationChannelsModel: LocationChannelsModel? = nil,
peerIdentityStore: PeerIdentityStore? = nil
) {
self.chatViewModel = chatViewModel
self.conversationStore = conversationStore
self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel()
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
let initialPeerID = conversationStore.selectedPrivatePeerID
self.selectedPeerID = initialPeerID
self.selectedHeaderState = initialPeerID.flatMap { peerID in
makeHeaderState(for: peerID)
}
bind()
}
func startConversation(with peerID: PeerID) {
chatViewModel.startPrivateChat(with: peerID)
refreshSelectedConversation()
}
func openConversation(for peerID: PeerID) {
if peerID.isGeoChat {
guard let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) else { return }
chatViewModel.startGeohashDM(withPubkeyHex: full)
} else {
chatViewModel.startPrivateChat(with: peerID)
}
refreshSelectedConversation()
}
func endConversation() {
chatViewModel.endPrivateChat()
refreshSelectedConversation()
}
func toggleFavorite(peerID: PeerID) {
chatViewModel.toggleFavorite(peerID: peerID)
refreshSelectedConversation()
}
func toggleFavoriteForSelectedConversation() {
guard let headerPeerID = selectedHeaderState?.headerPeerID else { return }
toggleFavorite(peerID: headerPeerID)
}
func markMessagesAsRead(from peerID: PeerID) {
chatViewModel.markPrivateMessagesAsRead(from: peerID)
}
private func bind() {
conversationStore.$selectedPrivatePeerID
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshSelectedConversation()
}
.store(in: &cancellables)
chatViewModel.$allPeers
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshSelectedConversation()
}
.store(in: &cancellables)
peerIdentityStore.$encryptionStatuses
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshSelectedConversation()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .favoriteStatusChanged)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshSelectedConversation()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshSelectedConversation()
}
.store(in: &cancellables)
locationChannelsModel.$selectedChannel
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshSelectedConversation()
}
.store(in: &cancellables)
}
private func refreshSelectedConversation() {
selectedPeerID = conversationStore.selectedPrivatePeerID
selectedHeaderState = selectedPeerID.flatMap { peerID in
makeHeaderState(for: peerID)
}
}
private func makeHeaderState(for conversationPeerID: PeerID) -> PrivateConversationHeaderState {
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
let peer = chatViewModel.getPeer(byID: headerPeerID)
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
let availability = resolveAvailability(for: headerPeerID, peer: peer)
let encryptionStatus: EncryptionStatus? = conversationPeerID.isGeoDM
? nil
: chatViewModel.getEncryptionStatus(for: headerPeerID)
return PrivateConversationHeaderState(
conversationPeerID: conversationPeerID,
headerPeerID: headerPeerID,
displayName: displayName,
availability: availability,
isFavorite: chatViewModel.isFavorite(peerID: headerPeerID),
encryptionStatus: encryptionStatus
)
}
private func resolveDisplayName(
for conversationPeerID: PeerID,
headerPeerID: PeerID,
peer: BitchatPeer?
) -> String {
if conversationPeerID.isGeoDM, case .location(let channel) = locationChannelsModel.selectedChannel {
return "#\(channel.geohash)/@\(chatViewModel.geohashDisplayName(for: conversationPeerID))"
}
if let displayName = peer?.displayName {
return displayName
}
if let nickname = chatViewModel.meshService.peerNickname(peerID: headerPeerID) {
return nickname
}
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(
for: Data(hexString: headerPeerID.id) ?? Data()
), !favorite.peerNickname.isEmpty {
return favorite.peerNickname
}
if headerPeerID.id.count == 16 {
let candidates = chatViewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID)
if let identity = candidates.first,
let social = chatViewModel.identityManager.getSocialIdentity(for: identity.fingerprint) {
if let pet = social.localPetname, !pet.isEmpty {
return pet
}
if !social.claimedNickname.isEmpty {
return social.claimedNickname
}
}
} else if let noiseKey = headerPeerID.noiseKey {
let fingerprint = noiseKey.sha256Fingerprint()
if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint) {
if let pet = social.localPetname, !pet.isEmpty {
return pet
}
if !social.claimedNickname.isEmpty {
return social.claimedNickname
}
}
}
return String(localized: "common.unknown", comment: "Fallback label for unknown peer")
}
private func resolveAvailability(for headerPeerID: PeerID, peer: BitchatPeer?) -> PrivateConversationAvailability {
if let connectionState = peer?.connectionState {
switch connectionState {
case .bluetoothConnected:
return .bluetoothConnected
case .meshReachable:
return .meshReachable
case .nostrAvailable:
return .nostrAvailable
case .offline:
return .offline
}
}
if chatViewModel.meshService.isPeerReachable(headerPeerID) {
return .meshReachable
}
if let noiseKey = Data(hexString: headerPeerID.id),
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
favoriteStatus.isMutual {
return .nostrAvailable
}
if chatViewModel.meshService.isPeerConnected(headerPeerID) || chatViewModel.connectedPeers.contains(headerPeerID) {
return .bluetoothConnected
}
return .offline
}
}
+41
View File
@@ -0,0 +1,41 @@
import BitFoundation
import Combine
import SwiftUI
@MainActor
final class PublicChatModel: ObservableObject {
@Published private(set) var activeChannel: ChannelID
@Published private(set) var messages: [BitchatMessage] = []
private let conversationStore: ConversationStore
private var cancellables = Set<AnyCancellable>()
init(conversationStore: ConversationStore) {
self.activeChannel = conversationStore.activeChannel
self.conversationStore = conversationStore
bind()
refreshMessages()
}
private func bind() {
conversationStore.$activeChannel
.receive(on: DispatchQueue.main)
.sink { [weak self] channel in
self?.activeChannel = channel
self?.refreshMessages()
}
.store(in: &cancellables)
conversationStore.$messagesByConversation
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshMessages()
}
.store(in: &cancellables)
}
private func refreshMessages() {
messages = conversationStore.messages(for: ConversationID(channelID: activeChannel))
}
}
+152
View File
@@ -0,0 +1,152 @@
import BitFoundation
import Combine
import Foundation
struct FingerprintPresentationState: Equatable {
let statusPeerID: PeerID
let peerNickname: String
let encryptionStatus: EncryptionStatus
let theirFingerprint: String?
let myFingerprint: String
let isVerified: Bool
var canToggleVerification: Bool {
encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified
}
}
enum VerificationScanOutcome: Equatable {
case requested(String)
case notFound
case invalid
}
@MainActor
final class VerificationModel: ObservableObject {
@Published private(set) var currentNickname: String
@Published private(set) var selectedPeerID: PeerID?
private let chatViewModel: ChatViewModel
private let peerIdentityStore: PeerIdentityStore
private var cancellables = Set<AnyCancellable>()
init(
chatViewModel: ChatViewModel,
privateConversationModel: PrivateConversationModel,
peerIdentityStore: PeerIdentityStore? = nil
) {
self.chatViewModel = chatViewModel
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
self.currentNickname = chatViewModel.nickname
self.selectedPeerID = privateConversationModel.selectedPeerID
bind(privateConversationModel: privateConversationModel)
}
func myQRString() -> String {
let npub = try? chatViewModel.idBridge.getCurrentNostrIdentity()?.npub
return VerificationService.shared.buildMyQRString(nickname: currentNickname, npub: npub) ?? ""
}
func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool {
chatViewModel.beginQRVerification(with: qr)
}
func verifyScannedPayload(_ payload: String) -> VerificationScanOutcome {
guard let qr = VerificationService.shared.verifyScannedQR(payload) else {
return .invalid
}
guard chatViewModel.beginQRVerification(with: qr) else {
return .notFound
}
return .requested(qr.nickname)
}
func verifyFingerprint(for peerID: PeerID) {
chatViewModel.verifyFingerprint(for: peerID)
}
func unverifyFingerprint(for peerID: PeerID) {
chatViewModel.unverifyFingerprint(for: peerID)
}
func isVerified(peerID: PeerID) -> Bool {
guard let fingerprint = chatViewModel.getFingerprint(for: peerID) else { return false }
return peerIdentityStore.isVerified(fingerprint)
}
func fingerprintPresentation(for peerID: PeerID) -> FingerprintPresentationState {
let statusPeerID = chatViewModel.getShortIDForNoiseKey(peerID)
let encryptionStatus = chatViewModel.getEncryptionStatus(for: statusPeerID)
let theirFingerprint = chatViewModel.getFingerprint(for: statusPeerID)
let peerNickname = resolveDisplayName(for: peerID, statusPeerID: statusPeerID)
return FingerprintPresentationState(
statusPeerID: statusPeerID,
peerNickname: peerNickname,
encryptionStatus: encryptionStatus,
theirFingerprint: theirFingerprint,
myFingerprint: chatViewModel.getMyFingerprint(),
isVerified: theirFingerprint.map { peerIdentityStore.isVerified($0) } ?? false
)
}
private func bind(privateConversationModel: PrivateConversationModel) {
chatViewModel.$nickname
.receive(on: DispatchQueue.main)
.assign(to: &$currentNickname)
privateConversationModel.$selectedPeerID
.receive(on: DispatchQueue.main)
.assign(to: &$selectedPeerID)
peerIdentityStore.$encryptionStatuses
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.objectWillChange.send()
}
.store(in: &cancellables)
peerIdentityStore.$verifiedFingerprints
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.objectWillChange.send()
}
.store(in: &cancellables)
chatViewModel.$allPeers
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.objectWillChange.send()
}
.store(in: &cancellables)
}
private func resolveDisplayName(for peerID: PeerID, statusPeerID: PeerID) -> String {
if let peer = chatViewModel.getPeer(byID: statusPeerID) {
return peer.displayName
}
if let name = chatViewModel.meshService.peerNickname(peerID: statusPeerID) {
return name
}
if let data = peerID.noiseKey {
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: data),
!favorite.peerNickname.isEmpty {
return favorite.peerNickname
}
let fingerprint = data.sha256Fingerprint()
if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint) {
if let pet = social.localPetname, !pet.isEmpty {
return pet
}
if !social.claimedNickname.isEmpty {
return social.claimedNickname
}
}
}
return String(localized: "common.unknown", comment: "Label for an unknown peer")
}
}
@@ -13,7 +13,6 @@
"value" : "dark" "value" : "dark"
} }
], ],
"filename" : "image-1024 1.png",
"idiom" : "universal", "idiom" : "universal",
"platform" : "ios", "platform" : "ios",
"size" : "1024x1024" "size" : "1024x1024"
@@ -25,7 +24,6 @@
"value" : "tinted" "value" : "tinted"
} }
], ],
"filename" : "image-1024 2.png",
"idiom" : "universal", "idiom" : "universal",
"platform" : "ios", "platform" : "ios",
"size" : "1024x1024" "size" : "1024x1024"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 11 KiB

+44 -191
View File
@@ -6,7 +6,6 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import Tor
import SwiftUI import SwiftUI
import UserNotifications import UserNotifications
@@ -14,113 +13,48 @@ import UserNotifications
struct BitchatApp: App { struct BitchatApp: App {
static let bundleID = Bundle.main.bundleIdentifier ?? "chat.bitchat" static let bundleID = Bundle.main.bundleIdentifier ?? "chat.bitchat"
static let groupID = "group.\(bundleID)" static let groupID = "group.\(bundleID)"
@StateObject private var chatViewModel: ChatViewModel @StateObject private var runtime: AppRuntime
#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
init() { init() {
let keychain = KeychainManager() _runtime = StateObject(wrappedValue: AppRuntime())
_chatViewModel = StateObject(
wrappedValue: ChatViewModel(
keychain: keychain,
identityManager: SecureIdentityStateManager(keychain)
)
)
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
// Warm up georelay directory and refresh if stale (once/day)
GeoRelayDirectory.shared.prefetchIfNeeded()
} }
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentView() ContentView()
.environmentObject(chatViewModel) .environmentObject(runtime.publicChatModel)
.environmentObject(runtime.privateInboxModel)
.environmentObject(runtime.privateConversationModel)
.environmentObject(runtime.verificationModel)
.environmentObject(runtime.conversationUIModel)
.environmentObject(runtime.locationChannelsModel)
.environmentObject(runtime.peerListModel)
.environmentObject(runtime.appChromeModel)
.onAppear { .onAppear {
NotificationDelegate.shared.chatViewModel = chatViewModel appDelegate.runtime = runtime
// Inject live Noise service into VerificationService to avoid creating new BLE instances runtime.start()
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
let nickname = chatViewModel.nickname
DispatchQueue.global(qos: .utility).async {
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
_ = VerificationService.shared.buildMyQRString(nickname: nickname, npub: npub)
}
#if os(iOS)
appDelegate.chatViewModel = chatViewModel
#elseif os(macOS)
appDelegate.chatViewModel = chatViewModel
#endif
// Initialize network activation policy; will start Tor/Nostr only when allowed
NetworkActivationService.shared.start()
// Check for shared content
checkForSharedContent()
} }
.onOpenURL { url in .onOpenURL { url in
handleURL(url) runtime.handleOpenURL(url)
} }
#if os(iOS) #if os(iOS)
.onChange(of: scenePhase) { newPhase in .onChange(of: scenePhase) { newPhase in
switch newPhase { runtime.handleScenePhaseChange(newPhase)
case .background:
// Keep BLE mesh running in background; BLEService adapts scanning automatically
// Always send Tor to dormant on background for a clean restart later.
TorManager.shared.setAppForeground(false)
TorManager.shared.goDormantOnBackground()
// Stop geohash sampling while backgrounded
Task { @MainActor in
chatViewModel.endGeohashSampling()
}
// Proactively disconnect Nostr to avoid spurious socket errors while Tor is down
NostrRelayManager.shared.disconnect()
didEnterBackground = true
case .active:
// Restart services when becoming active
chatViewModel.meshService.startServices()
TorManager.shared.setAppForeground(true)
// On initial cold launch, Tor was just started in onAppear.
// Skip the deterministic restart the first time we become active.
if didHandleInitialActive && didEnterBackground {
if TorManager.shared.isAutoStartAllowed() && !TorManager.shared.isReady {
TorManager.shared.ensureRunningOnForeground()
}
} else {
didHandleInitialActive = true
}
didEnterBackground = false
if TorManager.shared.isAutoStartAllowed() {
Task.detached {
let _ = await TorManager.shared.awaitReady(timeout: 60)
await MainActor.run {
// Rebuild proxied sessions to bind to the live Tor after readiness
TorURLSession.shared.rebuild()
// Reconnect Nostr via fresh sessions; will gate until Tor 100%
NostrRelayManager.shared.resetAllConnections()
}
}
}
checkForSharedContent()
case .inactive:
break
@unknown default:
break
}
} }
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
// Check for shared content when app becomes active runtime.handleDidBecomeActiveNotification()
checkForSharedContent()
} }
#elseif os(macOS) #elseif os(macOS)
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
// App became active runtime.handleMacDidBecomeActiveNotification()
} }
#endif #endif
} }
@@ -129,62 +63,18 @@ struct BitchatApp: App {
.windowResizability(.contentSize) .windowResizability(.contentSize)
#endif #endif
} }
private func handleURL(_ url: URL) {
if url.scheme == "bitchat" && url.host == "share" {
// Handle shared content
checkForSharedContent()
}
}
private func checkForSharedContent() {
// Check app group for shared content from extension
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else {
return
}
guard let sharedContent = userDefaults.string(forKey: "sharedContent"),
let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else {
return
}
// Only process if shared within configured window
if Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds {
let contentType = userDefaults.string(forKey: "sharedContentType") ?? "text"
// Clear the shared content
userDefaults.removeObject(forKey: "sharedContent")
userDefaults.removeObject(forKey: "sharedContentType")
userDefaults.removeObject(forKey: "sharedContentDate")
// No need to force synchronize here
// Send the shared content immediately on the main queue
DispatchQueue.main.async {
if contentType == "url" {
// Try to parse as JSON first
if let data = sharedContent.data(using: .utf8),
let urlData = try? JSONSerialization.jsonObject(with: data) as? [String: String],
let url = urlData["url"] {
// Send plain URL
self.chatViewModel.sendMessage(url)
} else {
// Fallback to simple URL
self.chatViewModel.sendMessage(sharedContent)
}
} else {
self.chatViewModel.sendMessage(sharedContent)
}
}
}
}
} }
#if os(iOS) #if os(iOS)
final class AppDelegate: NSObject, UIApplicationDelegate { final class AppDelegate: NSObject, UIApplicationDelegate {
weak var chatViewModel: ChatViewModel? weak var runtime: AppRuntime?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
return true true
}
func applicationWillTerminate(_ application: UIApplication) {
runtime?.applicationWillTerminate()
} }
} }
#endif #endif
@@ -193,79 +83,42 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
import AppKit import AppKit
final class MacAppDelegate: NSObject, NSApplicationDelegate { final class MacAppDelegate: NSObject, NSApplicationDelegate {
weak var chatViewModel: ChatViewModel? weak var runtime: AppRuntime?
func applicationWillTerminate(_ notification: Notification) { func applicationWillTerminate(_ notification: Notification) {
chatViewModel?.applicationWillTerminate() runtime?.applicationWillTerminate()
} }
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true true
} }
} }
#endif #endif
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate { final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
static let shared = NotificationDelegate() static let shared = NotificationDelegate()
weak var chatViewModel: ChatViewModel? weak var runtime: AppRuntime?
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
let identifier = response.notification.request.identifier let identifier = response.notification.request.identifier
let userInfo = response.notification.request.content.userInfo let userInfo = response.notification.request.content.userInfo
// Check if this is a private message notification Task { @MainActor in
if identifier.hasPrefix("private-") { self.runtime?.handleNotificationResponse(identifier: identifier, userInfo: userInfo)
// Get peer ID from userInfo
if let peerID = userInfo["peerID"] as? String {
DispatchQueue.main.async {
self.chatViewModel?.startPrivateChat(with: 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()
} }
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
let identifier = notification.request.identifier let identifier = notification.request.identifier
let userInfo = notification.request.content.userInfo let userInfo = notification.request.content.userInfo
Task { @MainActor [weak self] in Task {
guard let self = self else { let options = await self.runtime?.presentationOptions(
completionHandler([.banner, .sound]) forNotificationIdentifier: identifier,
return userInfo: userInfo
} ) ?? [.banner, .sound]
// Check if this is a private message notification and chat already open completionHandler(options)
if identifier.hasPrefix("private-"),
let peerID = userInfo["peerID"] as? String,
self.chatViewModel?.selectedPrivateChatPeer == peerID {
completionHandler([])
return
}
// 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,
case .location(let ch) = LocationChannelManager.shared.selectedChannel,
ch.geohash == gh {
completionHandler([])
return
}
completionHandler([.banner, .sound])
} }
} }
} }
extension String {
var nilIfEmpty: String? {
self.isEmpty ? nil : self
}
}
+217
View File
@@ -0,0 +1,217 @@
import Foundation
import ImageIO
import UniformTypeIdentifiers
#if os(iOS)
import UIKit
#else
import AppKit
#endif
enum ImageUtilsError: Error {
case invalidImage
case encodingFailed
}
enum ImageUtils {
private static let compressionQuality: CGFloat = 0.82
private static let targetImageBytes: Int = 45_000
private static let maxSourceImageBytes: Int = 10 * 1024 * 1024
static func processImage(at url: URL, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
try validateImageSource(at: url)
let data = try Data(contentsOf: url)
#if os(iOS)
guard let image = UIImage(data: data) else { throw ImageUtilsError.invalidImage }
return try processImage(image, maxDimension: maxDimension, outputDirectory: outputDirectory)
#else
guard let image = NSImage(data: data) else { throw ImageUtilsError.invalidImage }
return try processImage(image, maxDimension: maxDimension, outputDirectory: outputDirectory)
#endif
}
static func validateImageSource(at url: URL) throws {
// Security H1: Check file size BEFORE reading into memory.
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
guard let fileSize = attrs[.size] as? Int,
fileSize > 0,
fileSize <= maxSourceImageBytes else {
throw ImageUtilsError.invalidImage
}
let options = [kCGImageSourceShouldCache: false] as CFDictionary
guard let source = CGImageSourceCreateWithURL(url as CFURL, options),
CGImageSourceGetType(source) != nil else {
throw ImageUtilsError.invalidImage
}
}
#if os(iOS)
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
return try autoreleasepool {
// Scale the image first
let scaled = scaledImage(image, maxDimension: maxDimension)
// Get CGImage from UIImage - this is the key to stripping metadata
guard let cgImage = scaled.cgImage else {
throw ImageUtilsError.encodingFailed
}
// Use CGImageDestination to encode without metadata (same as macOS)
var quality = compressionQuality
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
throw ImageUtilsError.encodingFailed
}
// Compress to target size
while jpegData.count > targetImageBytes && quality > 0.3 {
quality -= 0.1
autoreleasepool {
if let next = encodeJPEG(from: cgImage, quality: quality) {
jpegData = next
}
}
}
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
try jpegData.write(to: outputURL, options: .atomic)
return outputURL
}
}
private static func scaledImage(_ image: UIImage, maxDimension: CGFloat) -> UIImage {
let size = image.size
let maxSide = max(size.width, size.height)
guard maxSide > maxDimension else { return image }
let scale = maxDimension / maxSide
let newSize = CGSize(width: size.width * scale, height: size.height * scale)
// Draw into a new context to get a clean CGImage without metadata
UIGraphicsBeginImageContextWithOptions(newSize, true, 1.0)
image.draw(in: CGRect(origin: .zero, size: newSize))
let rendered = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return rendered ?? image
}
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
guard let data = CFDataCreateMutable(nil, 0) else {
return nil
}
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
return nil
}
// Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
// By only specifying compression quality and no metadata keys,
// we ensure a clean JPEG with no privacy-leaking information
let options: [CFString: Any] = [
kCGImageDestinationLossyCompressionQuality: quality
]
CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
guard CGImageDestinationFinalize(destination) else {
return nil
}
return data as Data
}
#else
static func processImage(_ image: NSImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
return try autoreleasepool {
let scaled = scaledImage(image, maxDimension: maxDimension)
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
throw ImageUtilsError.encodingFailed
}
let width = inputCG.width
let height = inputCG.height
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
guard let context = CGContext(
data: nil,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: 0,
space: colorSpace,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else {
throw ImageUtilsError.encodingFailed
}
context.draw(inputCG, in: CGRect(x: 0, y: 0, width: width, height: height))
guard let cgImage = context.makeImage() else {
throw ImageUtilsError.encodingFailed
}
var quality = compressionQuality
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
throw ImageUtilsError.encodingFailed
}
while jpegData.count > targetImageBytes && quality > 0.3 {
quality -= 0.1
autoreleasepool {
if let next = encodeJPEG(from: cgImage, quality: quality) {
jpegData = next
}
}
}
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
try jpegData.write(to: outputURL, options: .atomic)
return outputURL
}
}
private static func scaledImage(_ image: NSImage, maxDimension: CGFloat) -> NSImage {
let size = image.size
let maxSide = max(size.width, size.height)
guard maxSide > maxDimension else { return image }
let scale = maxDimension / maxSide
let newSize = NSSize(width: size.width * scale, height: size.height * scale)
let scaledImage = NSImage(size: newSize)
scaledImage.lockFocus()
image.draw(in: NSRect(origin: .zero, size: newSize),
from: NSRect(origin: .zero, size: size),
operation: .copy,
fraction: 1.0)
scaledImage.unlockFocus()
return scaledImage
}
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
guard let data = CFDataCreateMutable(nil, 0) else {
return nil
}
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
return nil
}
// Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
// By only specifying compression quality and no metadata keys,
// we ensure a clean JPEG with no privacy-leaking information
let options: [CFString: Any] = [
kCGImageDestinationLossyCompressionQuality: quality
]
CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
guard CGImageDestinationFinalize(destination) else {
return nil
}
return data as Data
}
#endif
private static func makeOutputURL(outputDirectory: URL? = nil) throws -> URL {
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd_HHmmss"
let fileName = "img_\(formatter.string(from: Date()))_\(UUID().uuidString).jpg"
let directory: URL
if let outputDirectory {
directory = outputDirectory
} else {
directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true)
}
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
return directory.appendingPathComponent(fileName)
}
private static func applicationFilesDirectory() throws -> URL {
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
return base.appendingPathComponent("files", isDirectory: true)
}
}
@@ -0,0 +1,205 @@
import Foundation
import AVFoundation
import BitLogger
/// Controls playback for a single voice note and coordinates exclusive playback across the app.
final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlayerDelegate {
@Published private(set) var isPlaying: Bool = false
@Published private(set) var currentTime: TimeInterval = 0
@Published private(set) var duration: TimeInterval = 0
@Published private(set) var progress: Double = 0
/// rounded so 4.9s shows "00:05"
var roundedDuration: Int {
guard duration.isFinite else { return 0 }
return Int(duration.rounded())
}
/// ceil so "00:01" stays visible until playback ends, capped to rounded duration
var remainingSeconds: Int {
let remaining = max(0, duration - currentTime)
return min(roundedDuration, Int(ceil(remaining)))
}
private var player: AVAudioPlayer?
private var timer: Timer?
private var url: URL
init(url: URL) {
self.url = url
super.init()
// Don't load anything eagerly - wait until user interaction or view is fully displayed
}
func loadDuration() {
guard duration == 0 else { return }
DispatchQueue.global(qos: .utility).async { [weak self] in
guard let self = self else { return }
do {
let player = try AVAudioPlayer(contentsOf: self.url)
let loadedDuration = player.duration
DispatchQueue.main.async { [weak self] in
guard let self = self, self.duration == 0 else { return }
self.duration = loadedDuration
}
} catch {
SecureLogger.error("Failed to load audio duration: \(error)", category: .session)
}
}
}
deinit {
timer?.invalidate()
}
func replaceURL(_ url: URL) {
guard url != self.url else { return }
stop()
self.url = url
player = nil
duration = 0
// Duration will be loaded on demand when needed
}
func togglePlayback() {
isPlaying ? pause() : play()
}
func play() {
guard ensurePlayerReady() else { return }
VoiceNotePlaybackCoordinator.shared.activate(self)
player?.play()
startTimer()
updateProgress()
isPlaying = true
}
func pause() {
player?.pause()
stopTimer()
updateProgress()
isPlaying = false
}
func stop() {
player?.stop()
player?.currentTime = 0
stopTimer()
updateProgress()
isPlaying = false
VoiceNotePlaybackCoordinator.shared.deactivate(self)
}
func seek(to fraction: Double) {
guard ensurePlayerReady() else { return }
let clamped = max(0, min(1, fraction))
if let player = player {
player.currentTime = clamped * player.duration
if isPlaying {
player.play()
}
updateProgress()
}
}
// MARK: - AVAudioPlayerDelegate
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
// Delegate callback may be on background thread - ensure main thread for UI updates
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.stopTimer()
self.updateProgress()
self.isPlaying = false
VoiceNotePlaybackCoordinator.shared.deactivate(self)
}
}
// MARK: - Private Helpers
private func preparePlayer(for url: URL) {
// Prepare player synchronously (only called when playback is requested)
do {
let player = try AVAudioPlayer(contentsOf: url)
player.delegate = self
player.prepareToPlay()
self.player = player
duration = player.duration
currentTime = player.currentTime
progress = duration > 0 ? currentTime / duration : 0
} catch {
SecureLogger.error("Voice note playback failed for \(url.lastPathComponent): \(error)", category: .session)
player = nil
duration = 0
currentTime = 0
progress = 0
}
}
private func ensurePlayerReady() -> Bool {
if player == nil {
preparePlayer(for: url)
}
#if os(iOS)
let session = AVAudioSession.sharedInstance()
do {
try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers])
try session.setActive(true, options: [])
} catch {
SecureLogger.error("Failed to activate audio session: \(error)", category: .session)
}
#endif
return player != nil
}
private func startTimer() {
if timer != nil { return }
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
self?.updateProgress()
}
if let timer = timer {
RunLoop.main.add(timer, forMode: .common)
}
}
private func stopTimer() {
timer?.invalidate()
timer = nil
}
private func updateProgress() {
guard let player = player else {
currentTime = 0
duration = 0
progress = 0
return
}
currentTime = player.currentTime
duration = player.duration
progress = duration > 0 ? currentTime / duration : 0
}
}
/// Ensures only one voice note plays at a time.
final class VoiceNotePlaybackCoordinator {
static let shared = VoiceNotePlaybackCoordinator()
private weak var activeController: VoiceNotePlaybackController?
private init() {}
func activate(_ controller: VoiceNotePlaybackController) {
if activeController === controller {
return
}
activeController?.pause()
activeController = controller
}
func deactivate(_ controller: VoiceNotePlaybackController) {
if activeController === controller {
activeController = nil
}
}
}
+155
View File
@@ -0,0 +1,155 @@
import Foundation
import AVFoundation
/// Manages audio capture for mesh voice notes with predictable encoding settings.
actor VoiceRecorder {
enum RecorderError: Error {
case microphoneAccessDenied
case recorderInitializationFailed
case recordingInProgress
}
static let shared = VoiceRecorder()
private let paddingInterval: TimeInterval = 0.5
private let maxRecordingDuration: TimeInterval = 120
static let minRecordingDuration: TimeInterval = 1
private var recorder: AVAudioRecorder?
private var currentURL: URL?
// MARK: - Permissions
nonisolated
func requestPermission() async -> Bool {
#if os(iOS)
return await withCheckedContinuation { continuation in
AVAudioSession.sharedInstance().requestRecordPermission { granted in
continuation.resume(returning: granted)
}
}
#elseif os(macOS)
return await withCheckedContinuation { continuation in
AVCaptureDevice.requestAccess(for: .audio) { granted in
continuation.resume(returning: granted)
}
}
#else
return true
#endif
}
// MARK: - Recording Lifecycle
@discardableResult
func startRecording() throws -> URL {
if recorder?.isRecording == true {
throw RecorderError.recordingInProgress
}
#if os(iOS)
let session = AVAudioSession.sharedInstance()
guard session.recordPermission == .granted else {
throw RecorderError.microphoneAccessDenied
}
#if targetEnvironment(simulator)
// allowBluetoothHFP is not available on iOS Simulator
try session.setCategory(
.playAndRecord,
mode: .default,
options: [.defaultToSpeaker, .allowBluetoothA2DP]
)
#else
try session.setCategory(
.playAndRecord,
mode: .default,
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
)
#endif
try session.setActive(true, options: .notifyOthersOnDeactivation)
#endif
#if os(macOS)
guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
throw RecorderError.microphoneAccessDenied
}
#endif
let outputURL = try makeOutputURL()
let settings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 16_000,
AVNumberOfChannelsKey: 1,
AVEncoderBitRateKey: 16_000
]
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
audioRecorder.isMeteringEnabled = true
audioRecorder.prepareToRecord()
audioRecorder.record(forDuration: maxRecordingDuration)
recorder = audioRecorder
currentURL = outputURL
return outputURL
}
func stopRecording() async -> URL? {
guard let recorder, recorder.isRecording else {
return currentURL
}
let sessionURL = currentURL
try? await Task.sleep(nanoseconds: UInt64(paddingInterval * 1_000_000_000))
recorder.stop()
// A new session may have started during the sleep don't touch its state
if self.recorder === recorder {
cleanupSession()
self.recorder = nil
currentURL = nil
}
return sessionURL
}
func cancelRecording() {
if let recorder, recorder.isRecording {
recorder.stop()
}
cleanupSession()
if let currentURL {
try? FileManager.default.removeItem(at: currentURL)
}
recorder = nil
currentURL = nil
}
// MARK: - Helpers
private func makeOutputURL() throws -> URL {
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd_HHmmss"
let fileName = "voice_\(formatter.string(from: Date())).m4a"
let baseDirectory = try applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true)
try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: nil)
return baseDirectory.appendingPathComponent(fileName)
}
private func applicationFilesDirectory() throws -> URL {
#if os(iOS)
return try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
.appendingPathComponent("files", isDirectory: true)
#else
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
return base.appendingPathComponent("files", isDirectory: true)
#endif
}
private func cleanupSession() {
#if os(iOS)
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
#endif
}
}
+113
View File
@@ -0,0 +1,113 @@
import AVFoundation
import Foundation
import BitLogger
/// Generates and caches downsampled waveforms for audio files so UI rendering is cheap.
final class WaveformCache {
static let shared = WaveformCache()
private let queue = DispatchQueue(label: "com.bitchat.waveform-cache", attributes: .concurrent)
private var cache: [URL: (waveform: [Float], lastAccess: Date)] = [:]
private let maxCacheSize = 20 // Limit cache to prevent unbounded memory growth
private init() {}
func cachedWaveform(for url: URL) -> [Float]? {
queue.sync {
guard let entry = cache[url] else { return nil }
return entry.waveform
}
}
func waveform(for url: URL, bins: Int = 120, completion: @escaping ([Float]) -> Void) {
queue.async { [weak self] in
guard let self = self else { return }
// Check cache (read-only, no update needed on cache hit for performance)
if let entry = self.cache[url] {
DispatchQueue.main.async { completion(entry.waveform) }
return
}
guard let computed = self.computeWaveform(url: url, bins: bins) else {
DispatchQueue.main.async { completion([]) }
return
}
self.queue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
// Evict oldest entry if cache is full
if self.cache.count >= self.maxCacheSize {
if let oldest = self.cache.min(by: { $0.value.lastAccess < $1.value.lastAccess }) {
self.cache.removeValue(forKey: oldest.key)
}
}
self.cache[url] = (computed, Date())
}
DispatchQueue.main.async { completion(computed) }
}
}
func purge(url: URL) {
queue.async(flags: .barrier) { [weak self] in
self?.cache.removeValue(forKey: url)
}
}
func purgeAll() {
queue.async(flags: .barrier) { [weak self] in
self?.cache.removeAll()
}
}
private func computeWaveform(url: URL, bins: Int) -> [Float]? {
guard bins > 0 else { return nil }
// Use autoreleasepool to manage memory from audio buffer allocations
return autoreleasepool {
do {
let audioFile = try AVAudioFile(forReading: url)
let length = Int(audioFile.length)
guard length > 0 else { return nil }
guard let buffer = AVAudioPCMBuffer(pcmFormat: audioFile.processingFormat, frameCapacity: AVAudioFrameCount(length)) else {
return nil
}
try audioFile.read(into: buffer, frameCount: AVAudioFrameCount(length))
guard let channelData = buffer.floatChannelData else { return nil }
let channelCount = Int(audioFile.processingFormat.channelCount)
let frameLength = Int(buffer.frameLength)
let samplesPerBin = max(1, frameLength / bins)
var magnitudes: [Float] = Array(repeating: 0, count: bins)
for bin in 0..<bins {
let start = bin * samplesPerBin
let end = min(frameLength, start + samplesPerBin)
if start >= end { break }
var sum: Float = 0
var sampleCount = 0
for frame in start..<end {
var sampleValue: Float = 0
for channel in 0..<channelCount {
sampleValue += fabsf(channelData[channel][frame])
}
sum += sampleValue / Float(channelCount)
sampleCount += 1
}
magnitudes[bin] = sampleCount > 0 ? sum / Float(sampleCount) : 0
}
if let maxMagnitude = magnitudes.max(), maxMagnitude > 0 {
magnitudes = magnitudes.map { min($0 / maxMagnitude, 1.0) }
}
return magnitudes
} catch {
SecureLogger.error("Waveform extraction failed for \(url.lastPathComponent): \(error)", category: .session)
return nil
}
}
}
}
+1
View File
@@ -81,6 +81,7 @@
/// ///
import Foundation import Foundation
import BitFoundation
// MARK: - Three-Layer Identity Model // MARK: - Three-Layer Identity Model
@@ -91,6 +91,7 @@
/// ///
import BitLogger import BitLogger
import BitFoundation
import Foundation import Foundation
import CryptoKit import CryptoKit
@@ -201,8 +202,12 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
let decryptedData = try AES.GCM.open(sealedBox, using: encryptionKey) let decryptedData = try AES.GCM.open(sealedBox, using: encryptionKey)
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 cache = IdentityCache()
SecureLogger.error(error, context: "Failed to load identity cache", category: .security) let deleted = keychain.deleteIdentityKey(forKey: cacheKey)
SecureLogger.warning(
"Discarded unreadable identity cache; starting fresh (deleted=\(deleted), error=\(error.localizedDescription))",
category: .security
)
} }
} }
@@ -331,16 +336,15 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
func updateSocialIdentity(_ identity: SocialIdentity) { func updateSocialIdentity(_ identity: SocialIdentity) {
queue.async(flags: .barrier) { queue.async(flags: .barrier) {
let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname
self.cache.socialIdentities[identity.fingerprint] = identity self.cache.socialIdentities[identity.fingerprint] = identity
// Update nickname index // Update nickname index
if let existingIdentity = self.cache.socialIdentities[identity.fingerprint] { if let previousClaimedNickname,
// Remove old nickname from index if changed previousClaimedNickname != identity.claimedNickname {
if existingIdentity.claimedNickname != identity.claimedNickname { self.cache.nicknameIndex[previousClaimedNickname]?.remove(identity.fingerprint)
self.cache.nicknameIndex[existingIdentity.claimedNickname]?.remove(identity.fingerprint) if self.cache.nicknameIndex[previousClaimedNickname]?.isEmpty == true {
if self.cache.nicknameIndex[existingIdentity.claimedNickname]?.isEmpty == true { self.cache.nicknameIndex.removeValue(forKey: previousClaimedNickname)
self.cache.nicknameIndex.removeValue(forKey: existingIdentity.claimedNickname)
}
} }
} }
@@ -532,4 +536,16 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
return cache.verifiedFingerprints return cache.verifiedFingerprints
} }
} }
var debugNicknameIndex: [String: Set<String>] {
queue.sync { cache.nicknameIndex }
}
func debugEphemeralSession(for peerID: PeerID) -> EphemeralIdentity? {
queue.sync { ephemeralSessions[peerID] }
}
func debugLastInteraction(for fingerprint: String) -> Date? {
queue.sync { cache.lastInteractions[fingerprint] }
}
} }
+6
View File
@@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>AppGroupID</key>
<string>$(APP_GROUP_ID)</string>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
@@ -39,6 +41,10 @@
<string>bitchat uses the camera to scan QR codes to verify peers.</string> <string>bitchat uses the camera to scan QR codes to verify peers.</string>
<key>NSLocationWhenInUseUsageDescription</key> <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>NSMicrophoneUsageDescription</key>
<string>bitchat uses the microphone to record voice notes that relay across the mesh.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>bluetooth-central</string> <string>bluetooth-central</string>
+24566 -23312
View File
File diff suppressed because it is too large Load Diff
+69
View File
@@ -0,0 +1,69 @@
//
// BitchatMessage+Media.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import Foundation
extension BitchatMessage {
enum Media {
case voice(URL)
case image(URL)
var url: URL {
switch self {
case .voice(let url), .image(let url):
return url
}
}
}
// Cache the directory lookup to avoid repeated FileManager calls during view rendering
private struct Cache {
let filesDir: URL?
static let shared = Cache()
private init() {
do {
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let filesDir = base.appendingPathComponent("files", isDirectory: true)
try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
self.filesDir = filesDir
} catch {
filesDir = nil
}
}
}
func mediaAttachment(for nickname: String) -> Media? {
guard let baseDirectory = Cache.shared.filesDir else { return nil }
func url(for category: MimeType.Category) -> URL? {
guard content.hasPrefix(category.messagePrefix),
let filename = String(content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty
else {
return nil
}
// Check outgoing first for sent messages, incoming for received
let subdir = sender == nickname ? "\(category.mediaDir)/outgoing" : "\(category.mediaDir)/incoming"
// Construct URL directly without fileExists check (avoids blocking disk I/O in view body)
// Files are checked during playback/display, so missing files fail gracefully
let directory = baseDirectory.appendingPathComponent(subdir, isDirectory: true)
return directory.appendingPathComponent(filename)
}
if let url = url(for: .audio) {
return .voice(url)
}
if let url = url(for: .image) {
return .image(url)
}
return nil
}
}
+1
View File
@@ -1,5 +1,6 @@
import Foundation import Foundation
import CoreBluetooth import CoreBluetooth
import BitFoundation
/// Represents a peer in the BitChat network with all associated metadata /// Represents a peer in the BitChat network with all associated metadata
struct BitchatPeer: Equatable { struct BitchatPeer: Equatable {
+58
View File
@@ -0,0 +1,58 @@
//
// CommandsInfo.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
// MARK: - CommandInfo Enum
enum CommandInfo: String, Identifiable {
case block
case clear
case hug
case message = "dm"
case slap
case unblock
case who
case favorite
case unfavorite
var id: String { rawValue }
var alias: String { "/" + rawValue }
var placeholder: String? {
switch self {
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
case .clear, .who:
return nil
}
}
var description: String {
switch self {
case .block: String(localized: "content.commands.block")
case .clear: String(localized: "content.commands.clear")
case .hug: String(localized: "content.commands.hug")
case .message: String(localized: "content.commands.message")
case .slap: String(localized: "content.commands.slap")
case .unblock: String(localized: "content.commands.unblock")
case .who: String(localized: "content.commands.who")
case .favorite: String(localized: "content.commands.favorite")
case .unfavorite: String(localized: "content.commands.unfavorite")
}
}
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .hug, .message, .slap, .who]
if isGeoPublic || isGeoDM {
return baseCommands + [.favorite, .unfavorite]
}
return baseCommands
}
}
+7 -6
View File
@@ -7,15 +7,16 @@
// //
import Foundation import Foundation
import BitFoundation
struct ReadReceipt: Codable { struct ReadReceipt: Codable {
let originalMessageID: String let originalMessageID: String
let receiptID: String let receiptID: String
var readerID: String // Who read it var readerID: PeerID // Who read it
let readerNickname: String let readerNickname: String
let timestamp: Date let timestamp: Date
init(originalMessageID: String, readerID: String, readerNickname: String) { init(originalMessageID: String, readerID: PeerID, readerNickname: String) {
self.originalMessageID = originalMessageID self.originalMessageID = originalMessageID
self.receiptID = UUID().uuidString self.receiptID = UUID().uuidString
self.readerID = readerID self.readerID = readerID
@@ -24,7 +25,7 @@ struct ReadReceipt: Codable {
} }
// For binary decoding // For binary decoding
private init(originalMessageID: String, receiptID: String, readerID: String, readerNickname: String, timestamp: Date) { private init(originalMessageID: String, receiptID: String, readerID: PeerID, readerNickname: String, timestamp: Date) {
self.originalMessageID = originalMessageID self.originalMessageID = originalMessageID
self.receiptID = receiptID self.receiptID = receiptID
self.readerID = readerID self.readerID = readerID
@@ -48,7 +49,7 @@ struct ReadReceipt: Codable {
data.appendUUID(receiptID) data.appendUUID(receiptID)
// ReaderID as 8-byte hex string // ReaderID as 8-byte hex string
var readerData = Data() var readerData = Data()
var tempID = readerID var tempID = readerID.id
while tempID.count >= 2 && readerData.count < 8 { while tempID.count >= 2 && readerData.count < 8 {
let hexByte = String(tempID.prefix(2)) let hexByte = String(tempID.prefix(2))
if let byte = UInt8(hexByte, radix: 16) { if let byte = UInt8(hexByte, radix: 16) {
@@ -78,8 +79,8 @@ struct ReadReceipt: Codable {
let receiptID = dataCopy.readUUID(at: &offset) else { return nil } let receiptID = dataCopy.readUUID(at: &offset) else { return nil }
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil } guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
let readerID = readerIDData.hexEncodedString() let readerID = PeerID(hexData: readerIDData)
guard PeerID(str: readerID).isValid else { return nil } guard readerID.isValid else { return nil }
guard let timestamp = dataCopy.readDate(at: &offset), guard let timestamp = dataCopy.readDate(at: &offset),
InputValidator.validateTimestamp(timestamp), InputValidator.validateTimestamp(timestamp),
+41 -2
View File
@@ -8,6 +8,18 @@ struct RequestSyncPacket {
let p: Int let p: Int
let m: UInt32 let m: UInt32
let data: Data let data: Data
let types: SyncTypeFlags?
let sinceTimestamp: UInt64?
let fragmentIdFilter: String?
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil, sinceTimestamp: UInt64? = nil, fragmentIdFilter: String? = nil) {
self.p = p
self.m = m
self.data = data
self.types = types
self.sinceTimestamp = sinceTimestamp
self.fragmentIdFilter = fragmentIdFilter
}
func encode() -> Data { func encode() -> Data {
var out = Data() var out = Data()
@@ -25,14 +37,27 @@ struct RequestSyncPacket {
putTLV(0x02, withUnsafeBytes(of: &mBE) { Data($0) }) putTLV(0x02, withUnsafeBytes(of: &mBE) { Data($0) })
// data // data
putTLV(0x03, data) putTLV(0x03, data)
if let typesData = types?.toData() {
putTLV(0x04, typesData)
}
if let ts = sinceTimestamp {
var tsBE = ts.bigEndian
putTLV(0x05, withUnsafeBytes(of: &tsBE) { Data($0) })
}
if let fid = fragmentIdFilter, let fidData = fid.data(using: .utf8) {
putTLV(0x06, fidData)
}
return out return out
} }
static func decode(from data: Data, maxAcceptBytes: Int = 1024) -> RequestSyncPacket? { static func decode(from data: Data, maxAcceptBytes: Int = 1024) -> RequestSyncPacket? {
var off = 0 var off = 0
var p: Int? = nil var p: Int? = nil
var m: UInt32? = nil var m: UInt32? = nil
var payload: Data? = nil var payload: Data? = nil
var types: SyncTypeFlags? = nil
var sinceTimestamp: UInt64? = nil
var fragmentIdFilter: String? = nil
while off + 3 <= data.count { while off + 3 <= data.count {
let t = Int(data[off]); off += 1 let t = Int(data[off]); off += 1
@@ -52,12 +77,26 @@ struct RequestSyncPacket {
case 0x03: case 0x03:
if v.count > maxAcceptBytes { return nil } if v.count > maxAcceptBytes { return nil }
payload = v payload = v
case 0x04:
if let decoded = SyncTypeFlags.decode(v) {
types = decoded
}
case 0x05:
if v.count == 8 {
var ts: UInt64 = 0
for b in v { ts = (ts << 8) | UInt64(b) }
sinceTimestamp = ts
}
case 0x06:
if let fid = String(data: v, encoding: .utf8) {
fragmentIdFilter = fid
}
default: default:
break // forward compatible; ignore unknown TLVs break // forward compatible; ignore unknown TLVs
} }
} }
guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil } 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) return RequestSyncPacket(p: pp, m: mm, data: dd, types: types, sinceTimestamp: sinceTimestamp, fragmentIdFilter: fragmentIdFilter)
} }
} }
+176 -51
View File
@@ -78,6 +78,7 @@
/// ///
import BitLogger import BitLogger
import BitFoundation
import Foundation import Foundation
import CryptoKit import CryptoKit
@@ -165,19 +166,23 @@ final class NoiseCipherState {
// MARK: - Sliding Window Replay Protection // MARK: - Sliding Window Replay Protection
/// Check if nonce is valid for replay protection /// Check if nonce is valid for replay protection
/// BCH-01-010: Use safe arithmetic to prevent integer overflow
private func isValidNonce(_ receivedNonce: UInt64) -> Bool { private func isValidNonce(_ receivedNonce: UInt64) -> Bool {
if receivedNonce + UInt64(Self.REPLAY_WINDOW_SIZE) <= highestReceivedNonce { // Safe overflow check: instead of (receivedNonce + WINDOW_SIZE <= highest)
// use (highest >= WINDOW_SIZE && receivedNonce <= highest - WINDOW_SIZE)
let windowSize = UInt64(Self.REPLAY_WINDOW_SIZE)
if highestReceivedNonce >= windowSize && receivedNonce <= highestReceivedNonce - windowSize {
return false // Too old, outside window return false // Too old, outside window
} }
if receivedNonce > highestReceivedNonce { if receivedNonce > highestReceivedNonce {
return true // Always accept newer nonces return true // Always accept newer nonces
} }
let offset = Int(highestReceivedNonce - receivedNonce) let offset = Int(highestReceivedNonce - receivedNonce)
let byteIndex = offset / 8 let byteIndex = offset / 8
let bitIndex = offset % 8 let bitIndex = offset % 8
return (replayWindow[byteIndex] & (1 << bitIndex)) == 0 // Not yet seen return (replayWindow[byteIndex] & (1 << bitIndex)) == 0 // Not yet seen
} }
@@ -222,7 +227,7 @@ final class NoiseCipherState {
guard combinedPayload.count >= Self.NONCE_SIZE_BYTES else { guard combinedPayload.count >= Self.NONCE_SIZE_BYTES else {
return nil return nil
} }
// Extract 4-byte nonce (big-endian) // Extract 4-byte nonce (big-endian)
let nonceData = combinedPayload.prefix(Self.NONCE_SIZE_BYTES) let nonceData = combinedPayload.prefix(Self.NONCE_SIZE_BYTES)
let extractedNonce = nonceData.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) -> UInt64 in let extractedNonce = nonceData.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) -> UInt64 in
@@ -233,18 +238,18 @@ final class NoiseCipherState {
} }
return result return result
} }
// Extract ciphertext (remaining bytes) // Extract ciphertext (remaining bytes)
let ciphertext = combinedPayload.dropFirst(Self.NONCE_SIZE_BYTES) let ciphertext = combinedPayload.dropFirst(Self.NONCE_SIZE_BYTES)
return (nonce: extractedNonce, ciphertext: Data(ciphertext)) return (nonce: extractedNonce, ciphertext: Data(ciphertext))
} }
/// Convert nonce to 4-byte array (big-endian) /// Convert nonce to 4-byte array (big-endian)
private func nonceToBytes(_ nonce: UInt64) -> Data { private func nonceToBytes(_ nonce: UInt64) -> Data {
var bytes = Data(count: Self.NONCE_SIZE_BYTES) var bytes = Data(count: Self.NONCE_SIZE_BYTES)
withUnsafeBytes(of: nonce.bigEndian) { ptr in withUnsafeBytes(of: nonce.bigEndian) { ptr in
// Copy only the last 4 bytes from the 8-byte UInt64 // Copy only the last 4 bytes from the 8-byte UInt64
let sourceBytes = ptr.bindMemory(to: UInt8.self) let sourceBytes = ptr.bindMemory(to: UInt8.self)
bytes.replaceSubrange(0..<Self.NONCE_SIZE_BYTES, with: sourceBytes.suffix(Self.NONCE_SIZE_BYTES)) bytes.replaceSubrange(0..<Self.NONCE_SIZE_BYTES, with: sourceBytes.suffix(Self.NONCE_SIZE_BYTES))
} }
@@ -273,7 +278,7 @@ final class NoiseCipherState {
let sealedBox = try ChaChaPoly.seal(plaintext, using: key, nonce: ChaChaPoly.Nonce(data: nonceData), authenticating: associatedData) let sealedBox = try ChaChaPoly.seal(plaintext, using: key, nonce: ChaChaPoly.Nonce(data: nonceData), authenticating: associatedData)
// increment local nonce // increment local nonce
nonce += 1 nonce += 1
// Create combined payload: <nonce><ciphertext> // Create combined payload: <nonce><ciphertext>
let combinedPayload: Data let combinedPayload: Data
if (useExtractedNonce) { if (useExtractedNonce) {
@@ -287,7 +292,7 @@ final class NoiseCipherState {
if currentNonce > Self.HIGH_NONCE_WARNING_THRESHOLD { if currentNonce > Self.HIGH_NONCE_WARNING_THRESHOLD {
SecureLogger.warning("High nonce value detected: \(currentNonce) - consider rekeying", category: .encryption) SecureLogger.warning("High nonce value detected: \(currentNonce) - consider rekeying", category: .encryption)
} }
return combinedPayload return combinedPayload
} }
@@ -316,7 +321,7 @@ final class NoiseCipherState {
SecureLogger.debug("Replay attack detected: nonce \(extractedNonce) rejected") SecureLogger.debug("Replay attack detected: nonce \(extractedNonce) rejected")
throw NoiseError.replayDetected throw NoiseError.replayDetected
} }
// Split ciphertext and tag // Split ciphertext and tag
encryptedData = actualCiphertext.prefix(actualCiphertext.count - 16) encryptedData = actualCiphertext.prefix(actualCiphertext.count - 16)
tag = actualCiphertext.suffix(16) tag = actualCiphertext.suffix(16)
@@ -347,16 +352,20 @@ final class NoiseCipherState {
do { do {
let plaintext = try ChaChaPoly.open(sealedBox, using: key, authenticating: associatedData) let plaintext = try ChaChaPoly.open(sealedBox, using: key, authenticating: associatedData)
// BCH-01-010: Atomic nonce state update
// Both replay window marking and nonce increment must complete together
// to prevent state desynchronization. We perform both after successful
// decryption only, ensuring state consistency on any failure path.
if useExtractedNonce { if useExtractedNonce {
// Mark nonce as seen after successful decryption
markNonceAsSeen(decryptionNonce) markNonceAsSeen(decryptionNonce)
} }
nonce += 1 nonce += 1
return plaintext return plaintext
} catch { } catch {
// Decryption failed - nonce state remains unchanged (atomic rollback)
SecureLogger.debug("Decrypt failed: \(error) for nonce \(decryptionNonce)") SecureLogger.debug("Decrypt failed: \(error) for nonce \(decryptionNonce)")
// Log authentication failures with nonce info
SecureLogger.error("Decryption failed at nonce \(decryptionNonce)", category: .encryption) SecureLogger.error("Decryption failed at nonce \(decryptionNonce)", category: .encryption)
throw error throw error
} }
@@ -376,6 +385,16 @@ final class NoiseCipherState {
replayWindow[i] = 0 replayWindow[i] = 0
} }
} }
#if DEBUG
func setNonceForTesting(_ nonce: UInt64) {
self.nonce = nonce
}
func extractNonceFromCiphertextPayloadForTesting(_ combinedPayload: Data) throws -> (nonce: UInt64, ciphertext: Data)? {
try extractNonceFromCiphertextPayload(combinedPayload)
}
#endif
} }
// MARK: - Symmetric State // MARK: - Symmetric State
@@ -451,17 +470,40 @@ final class NoiseSymmetricState {
} }
} }
func split() -> (NoiseCipherState, NoiseCipherState) { func split(useExtractedNonce: Bool) -> (NoiseCipherState, NoiseCipherState) {
let output = hkdf(chainingKey: chainingKey, inputKeyMaterial: Data(), numOutputs: 2) let output = hkdf(chainingKey: chainingKey, inputKeyMaterial: Data(), numOutputs: 2)
let tempKey1 = SymmetricKey(data: output[0]) let tempKey1 = SymmetricKey(data: output[0])
let tempKey2 = SymmetricKey(data: output[1]) let tempKey2 = SymmetricKey(data: output[1])
let c1 = NoiseCipherState(key: tempKey1, useExtractedNonce: true) let c1 = NoiseCipherState(key: tempKey1, useExtractedNonce: useExtractedNonce)
let c2 = NoiseCipherState(key: tempKey2, useExtractedNonce: true) let c2 = NoiseCipherState(key: tempKey2, useExtractedNonce: useExtractedNonce)
// BCH-01-010: Clear symmetric state after split per Noise spec
// The chaining key and hash should not be retained after handshake completes
clearSensitiveData()
return (c1, c2) return (c1, c2)
} }
/// BCH-01-010: Securely clear sensitive cryptographic state
/// Called after split() to clear chaining key and hash per Noise spec
func clearSensitiveData() {
// Clear chaining key by overwriting with zeros
let chainingKeyCount = chainingKey.count
chainingKey = Data(repeating: 0, count: chainingKeyCount)
// Clear hash by overwriting with zeros
let hashCount = hash.count
hash = Data(repeating: 0, count: hashCount)
// Clear the internal cipher state
cipherState.clearSensitiveData()
}
deinit {
clearSensitiveData()
}
// HKDF implementation // HKDF implementation
private func hkdf(chainingKey: Data, inputKeyMaterial: Data, numOutputs: Int) -> [Data] { private func hkdf(chainingKey: Data, inputKeyMaterial: Data, numOutputs: Int) -> [Data] {
let tempKey = HMAC<SHA256>.authenticationCode(for: inputKeyMaterial, using: SymmetricKey(data: chainingKey)) let tempKey = HMAC<SHA256>.authenticationCode(for: inputKeyMaterial, using: SymmetricKey(data: chainingKey))
@@ -507,16 +549,24 @@ final class NoiseHandshakeState {
private var messagePatterns: [[NoiseMessagePattern]] = [] private var messagePatterns: [[NoiseMessagePattern]] = []
private var currentPattern = 0 private var currentPattern = 0
// Test support: predetermined ephemeral keys for test vectors
private var predeterminedEphemeralKey: Curve25519.KeyAgreement.PrivateKey?
private var prologueData: Data
init( init(
role: NoiseRole, role: NoiseRole,
pattern: NoisePattern, pattern: NoisePattern,
keychain: KeychainManagerProtocol, keychain: KeychainManagerProtocol,
localStaticKey: Curve25519.KeyAgreement.PrivateKey? = nil, localStaticKey: Curve25519.KeyAgreement.PrivateKey? = nil,
remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil,
prologue: Data = Data(),
predeterminedEphemeralKey: Curve25519.KeyAgreement.PrivateKey? = nil
) { ) {
self.role = role self.role = role
self.pattern = pattern self.pattern = pattern
self.keychain = keychain self.keychain = keychain
self.prologueData = prologue
self.predeterminedEphemeralKey = predeterminedEphemeralKey
// Initialize static keys // Initialize static keys
if let localKey = localStaticKey { if let localKey = localStaticKey {
@@ -537,8 +587,8 @@ final class NoiseHandshakeState {
} }
private func mixPreMessageKeys() { private func mixPreMessageKeys() {
// Mix prologue (empty for XX pattern normally) // Mix prologue
symmetricState.mixHash(Data()) // Empty prologue for XX pattern symmetricState.mixHash(self.prologueData)
// For XX pattern, no pre-message keys // For XX pattern, no pre-message keys
// For IK/NK patterns, we'd mix the responder's static key here // For IK/NK patterns, we'd mix the responder's static key here
switch pattern { switch pattern {
@@ -546,8 +596,9 @@ final class NoiseHandshakeState {
break // No pre-message keys break // No pre-message keys
case .IK, .NK: case .IK, .NK:
if role == .initiator, let remoteStatic = remoteStaticPublic { if role == .initiator, let remoteStatic = remoteStaticPublic {
_ = symmetricState.getHandshakeHash()
symmetricState.mixHash(remoteStatic.rawRepresentation) symmetricState.mixHash(remoteStatic.rawRepresentation)
} else if role == .responder, let localStatic = localStaticPublic {
symmetricState.mixHash(localStatic.rawRepresentation)
} }
} }
} }
@@ -556,15 +607,20 @@ final class NoiseHandshakeState {
guard currentPattern < messagePatterns.count else { guard currentPattern < messagePatterns.count else {
throw NoiseError.handshakeComplete throw NoiseError.handshakeComplete
} }
var messageBuffer = Data() var messageBuffer = Data()
let patterns = messagePatterns[currentPattern] let patterns = messagePatterns[currentPattern]
for pattern in patterns { for pattern in patterns {
switch pattern { switch pattern {
case .e: case .e:
// Generate ephemeral key // Generate ephemeral key (or use predetermined key for tests)
localEphemeralPrivate = Curve25519.KeyAgreement.PrivateKey() if let predetermined = predeterminedEphemeralKey {
localEphemeralPrivate = predetermined
predeterminedEphemeralKey = nil
} else {
localEphemeralPrivate = Curve25519.KeyAgreement.PrivateKey()
}
localEphemeralPublic = localEphemeralPrivate!.publicKey localEphemeralPublic = localEphemeralPrivate!.publicKey
messageBuffer.append(localEphemeralPublic!.rawRepresentation) messageBuffer.append(localEphemeralPublic!.rawRepresentation)
symmetricState.mixHash(localEphemeralPublic!.rawRepresentation) symmetricState.mixHash(localEphemeralPublic!.rawRepresentation)
@@ -597,14 +653,20 @@ final class NoiseHandshakeState {
throw NoiseError.missingKeys throw NoiseError.missingKeys
} }
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic) let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic)
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) }) var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData)
// Clear sensitive shared secret
keychain.secureClear(&sharedData)
} else { } else {
guard let localStatic = localStaticPrivate, guard let localStatic = localStaticPrivate,
let remoteEphemeral = remoteEphemeralPublic else { let remoteEphemeral = remoteEphemeralPublic else {
throw NoiseError.missingKeys throw NoiseError.missingKeys
} }
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral) let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral)
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) }) var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData)
// Clear sensitive shared secret
keychain.secureClear(&sharedData)
} }
case .se: case .se:
@@ -615,14 +677,20 @@ final class NoiseHandshakeState {
throw NoiseError.missingKeys throw NoiseError.missingKeys
} }
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral) let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral)
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) }) var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData)
// Clear sensitive shared secret
keychain.secureClear(&sharedData)
} else { } else {
guard let localEphemeral = localEphemeralPrivate, guard let localEphemeral = localEphemeralPrivate,
let remoteStatic = remoteStaticPublic else { let remoteStatic = remoteStaticPublic else {
throw NoiseError.missingKeys throw NoiseError.missingKeys
} }
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic) let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic)
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) }) var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData)
// Clear sensitive shared secret
keychain.secureClear(&sharedData)
} }
case .ss: case .ss:
@@ -652,7 +720,7 @@ final class NoiseHandshakeState {
guard currentPattern < messagePatterns.count else { guard currentPattern < messagePatterns.count else {
throw NoiseError.handshakeComplete throw NoiseError.handshakeComplete
} }
var buffer = message var buffer = message
let patterns = messagePatterns[currentPattern] let patterns = messagePatterns[currentPattern]
@@ -711,8 +779,11 @@ final class NoiseHandshakeState {
throw NoiseError.missingKeys throw NoiseError.missingKeys
} }
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteEphemeral) let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteEphemeral)
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) }) var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData)
// Clear sensitive shared secret
keychain.secureClear(&sharedData)
case .es: case .es:
if role == .initiator { if role == .initiator {
guard let localEphemeral = localEphemeralPrivate, guard let localEphemeral = localEphemeralPrivate,
@@ -765,9 +836,12 @@ final class NoiseHandshakeState {
throw NoiseError.missingKeys throw NoiseError.missingKeys
} }
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteStatic) let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteStatic)
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) }) var sharedData = shared.withUnsafeBytes { Data($0) }
symmetricState.mixKey(sharedData)
default: // Clear sensitive shared secret
keychain.secureClear(&sharedData)
case .e, .s:
break break
} }
} }
@@ -776,16 +850,20 @@ final class NoiseHandshakeState {
return currentPattern >= messagePatterns.count return currentPattern >= messagePatterns.count
} }
func getTransportCiphers() throws -> (send: NoiseCipherState, receive: NoiseCipherState) { func getTransportCiphers(useExtractedNonce: Bool) throws -> (send: NoiseCipherState, receive: NoiseCipherState, handshakeHash: Data) {
guard isHandshakeComplete() else { guard isHandshakeComplete() else {
throw NoiseError.handshakeNotComplete throw NoiseError.handshakeNotComplete
} }
let (c1, c2) = symmetricState.split() // BCH-01-010: Capture handshake hash BEFORE split() clears symmetric state
let finalHandshakeHash = symmetricState.getHandshakeHash()
let (c1, c2) = symmetricState.split(useExtractedNonce: useExtractedNonce)
// Initiator uses c1 for sending, c2 for receiving // Initiator uses c1 for sending, c2 for receiving
// Responder uses c2 for sending, c1 for receiving // Responder uses c2 for sending, c1 for receiving
return role == .initiator ? (c1, c2) : (c2, c1) let ciphers = role == .initiator ? (c1, c2) : (c2, c1)
return (send: ciphers.0, receive: ciphers.1, handshakeHash: finalHandshakeHash)
} }
func getRemoteStaticPublicKey() -> Curve25519.KeyAgreement.PublicKey? { func getRemoteStaticPublicKey() -> Curve25519.KeyAgreement.PublicKey? {
@@ -795,6 +873,20 @@ final class NoiseHandshakeState {
func getHandshakeHash() -> Data { func getHandshakeHash() -> Data {
return symmetricState.getHandshakeHash() return symmetricState.getHandshakeHash()
} }
#if DEBUG
func performDHOperationForTesting(_ pattern: NoiseMessagePattern) throws {
try performDHOperation(pattern)
}
func setCurrentPatternForTesting(_ currentPattern: Int) {
self.currentPattern = currentPattern
}
func setRemoteEphemeralPublicKeyForTesting(_ key: Curve25519.KeyAgreement.PublicKey?) {
self.remoteEphemeralPublic = key
}
#endif
} }
// MARK: - Pattern Extensions // MARK: - Pattern Extensions
@@ -846,22 +938,47 @@ enum NoiseError: Error {
case nonceExceeded case nonceExceeded
} }
// MARK: - Constant-Time Operations
/// BCH-01-010: Constant-time comparison to prevent timing side-channel attacks
/// This function compares two Data objects in constant time, preventing
/// information leakage via timing analysis.
private func constantTimeCompare(_ a: Data, _ b: Data) -> Bool {
guard a.count == b.count else { return false }
var result: UInt8 = 0
for i in 0..<a.count {
result |= a[a.startIndex.advanced(by: i)] ^ b[b.startIndex.advanced(by: i)]
}
return result == 0
}
/// BCH-01-010: Constant-time check if all bytes are zero
private func constantTimeIsZero(_ data: Data) -> Bool {
var result: UInt8 = 0
for byte in data {
result |= byte
}
return result == 0
}
// MARK: - Key Validation // MARK: - Key Validation
extension NoiseHandshakeState { extension NoiseHandshakeState {
/// Validate a Curve25519 public key /// Validate a Curve25519 public key
/// Checks for weak/invalid keys that could compromise security /// Checks for weak/invalid keys that could compromise security
/// BCH-01-010: Uses constant-time operations to prevent timing side-channels
static func validatePublicKey(_ keyData: Data) throws -> Curve25519.KeyAgreement.PublicKey { static func validatePublicKey(_ keyData: Data) throws -> Curve25519.KeyAgreement.PublicKey {
// Check key length // Check key length
guard keyData.count == 32 else { guard keyData.count == 32 else {
throw NoiseError.invalidPublicKey throw NoiseError.invalidPublicKey
} }
// Check for all-zero key (point at infinity) // BCH-01-010: Constant-time check for all-zero key (point at infinity)
if keyData.allSatisfy({ $0 == 0 }) { if constantTimeIsZero(keyData) {
throw NoiseError.invalidPublicKey throw NoiseError.invalidPublicKey
} }
// Check for low-order points that could enable small subgroup attacks // Check for low-order points that could enable small subgroup attacks
// These are the known bad points for Curve25519 // These are the known bad points for Curve25519
let lowOrderPoints: [Data] = [ let lowOrderPoints: [Data] = [
@@ -882,13 +999,21 @@ extension NoiseHandshakeState {
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]) // Another bad point 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]) // Another bad point
] ]
// Check against known bad points // BCH-01-010: Constant-time check against known bad points
if lowOrderPoints.contains(keyData) { // We check all points and accumulate matches to avoid early exit timing leaks
var foundBadPoint = false
for badPoint in lowOrderPoints {
if constantTimeCompare(keyData, badPoint) {
foundBadPoint = true
}
}
if foundBadPoint {
SecureLogger.warning("Low-order point detected", category: .security) SecureLogger.warning("Low-order point detected", category: .security)
throw NoiseError.invalidPublicKey throw NoiseError.invalidPublicKey
} }
// Try to create the key - CryptoKit will validate curve points internally // Try to create the key - CryptoKit will validate curve points internally
do { do {
let publicKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: keyData) let publicKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: keyData)
+96
View File
@@ -0,0 +1,96 @@
//
// NoiseRateLimiter.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitLogger
import BitFoundation
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,227 +0,0 @@
//
// NoiseSecurityConsiderations.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitLogger
import Foundation
// 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
}
}
// MARK: - Enhanced Noise Session with Security
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
}
// MARK: - Rate Limiter
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()
}
}
}
// 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
}
}
+16 -21
View File
@@ -9,6 +9,7 @@
import BitLogger import BitLogger
import Foundation import Foundation
import CryptoKit import CryptoKit
import BitFoundation
class NoiseSession { class NoiseSession {
let peerID: PeerID let peerID: PeerID
@@ -102,23 +103,23 @@ class NoiseSession {
// Check if handshake is complete // Check if handshake is complete
if handshake.isHandshakeComplete() { if handshake.isHandshakeComplete() {
// Get transport ciphers // Get transport ciphers and handshake hash (hash captured before split clears state)
let (send, receive) = try handshake.getTransportCiphers() let (send, receive, hash) = try handshake.getTransportCiphers(useExtractedNonce: true)
sendCipher = send sendCipher = send
receiveCipher = receive receiveCipher = receive
// Store remote static key // Store remote static key
remoteStaticPublicKey = handshake.getRemoteStaticPublicKey() remoteStaticPublicKey = handshake.getRemoteStaticPublicKey()
// Store handshake hash for channel binding // Store handshake hash for channel binding
handshakeHash = handshake.getHandshakeHash() handshakeHash = hash
state = .established state = .established
handshakeState = nil // Clear handshake state handshakeState = nil // Clear handshake state
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established") SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established")
SecureLogger.info(.handshakeCompleted(peerID: peerID.id)) SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
return nil return nil
} else { } else {
// Generate response // Generate response
@@ -128,20 +129,20 @@ class NoiseSession {
// Check if handshake is complete after writing // Check if handshake is complete after writing
if handshake.isHandshakeComplete() { if handshake.isHandshakeComplete() {
// Get transport ciphers // Get transport ciphers and handshake hash (hash captured before split clears state)
let (send, receive) = try handshake.getTransportCiphers() let (send, receive, hash) = try handshake.getTransportCiphers(useExtractedNonce: true)
sendCipher = send sendCipher = send
receiveCipher = receive receiveCipher = receive
// Store remote static key // Store remote static key
remoteStaticPublicKey = handshake.getRemoteStaticPublicKey() remoteStaticPublicKey = handshake.getRemoteStaticPublicKey()
// Store handshake hash for channel binding // Store handshake hash for channel binding
handshakeHash = handshake.getHandshakeHash() handshakeHash = hash
state = .established state = .established
handshakeState = nil // Clear handshake state handshakeState = nil // Clear handshake state
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established") SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established")
SecureLogger.info(.handshakeCompleted(peerID: peerID.id)) SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
} }
@@ -196,12 +197,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
+1 -2
View File
@@ -6,10 +6,9 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
enum NoiseSessionError: Error { enum NoiseSessionError: Error, Equatable {
case invalidState case invalidState
case notEstablished case notEstablished
case sessionNotFound case sessionNotFound
case handshakeFailed(Error)
case alreadyEstablished case alreadyEstablished
} }
+22 -38
View File
@@ -9,11 +9,13 @@
import BitLogger import BitLogger
import CryptoKit import CryptoKit
import Foundation import Foundation
import BitFoundation
final class NoiseSessionManager { final class NoiseSessionManager {
private var sessions: [PeerID: NoiseSession] = [:] private var sessions: [PeerID: NoiseSession] = [:]
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
private let keychain: KeychainManagerProtocol private let keychain: KeychainManagerProtocol
private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent) private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
// Callbacks // Callbacks
@@ -23,22 +25,29 @@ final class NoiseSessionManager {
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) { init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
self.localStaticKey = localStaticKey self.localStaticKey = localStaticKey
self.keychain = keychain self.keychain = keychain
} self.sessionFactory = { peerID, role in
SecureNoiseSession(
// MARK: - Session Management
func createSession(for peerID: PeerID, role: NoiseRole) -> NoiseSession {
return managerQueue.sync(flags: .barrier) {
let session = SecureNoiseSession(
peerID: peerID, peerID: peerID,
role: role, role: role,
keychain: keychain, keychain: keychain,
localStaticKey: localStaticKey localStaticKey: localStaticKey
) )
sessions[peerID] = session
return session
} }
} }
#if DEBUG
init(
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
keychain: KeychainManagerProtocol,
sessionFactory: @escaping (PeerID, NoiseRole) -> NoiseSession
) {
self.localStaticKey = localStaticKey
self.keychain = keychain
self.sessionFactory = sessionFactory
}
#endif
// MARK: - Session Management
func getSession(for peerID: PeerID) -> NoiseSession? { func getSession(for peerID: PeerID) -> NoiseSession? {
return managerQueue.sync { return managerQueue.sync {
@@ -48,14 +57,9 @@ final class NoiseSessionManager {
func removeSession(for peerID: PeerID) { func removeSession(for peerID: PeerID) {
managerQueue.sync(flags: .barrier) { managerQueue.sync(flags: .barrier) {
if let session = sessions[peerID] { if let session = sessions.removeValue(forKey: peerID) {
if session.isEstablished() { session.reset() // Clear sensitive data before removing
SecureLogger.info(.sessionExpired(peerID: peerID.id))
}
// Clear sensitive data before removing
session.reset()
} }
_ = sessions.removeValue(forKey: peerID)
} }
} }
@@ -68,12 +72,6 @@ final class NoiseSessionManager {
} }
} }
func getEstablishedSessions() -> [PeerID: NoiseSession] {
return managerQueue.sync {
return sessions.filter { $0.value.isEstablished() }
}
}
// MARK: - Handshake Helpers // MARK: - Handshake Helpers
func initiateHandshake(with peerID: PeerID) throws -> Data { func initiateHandshake(with peerID: PeerID) throws -> Data {
@@ -90,12 +88,7 @@ final class NoiseSessionManager {
} }
// Create new initiator session // Create new initiator session
let session = SecureNoiseSession( let session = sessionFactory(peerID, .initiator)
peerID: peerID,
role: .initiator,
keychain: keychain,
localStaticKey: localStaticKey
)
sessions[peerID] = session sessions[peerID] = session
do { do {
@@ -141,12 +134,7 @@ final class NoiseSessionManager {
// Get or create session // Get or create session
let session: NoiseSession let session: NoiseSession
if shouldCreateNew { if shouldCreateNew {
let newSession = SecureNoiseSession( let newSession = sessionFactory(peerID, .responder)
peerID: peerID,
role: .responder,
keychain: keychain,
localStaticKey: localStaticKey
)
sessions[peerID] = newSession sessions[peerID] = newSession
session = newSession session = newSession
} else { } else {
@@ -207,10 +195,6 @@ final class NoiseSessionManager {
return getSession(for: peerID)?.getRemoteStaticPublicKey() return getSession(for: peerID)?.getRemoteStaticPublicKey()
} }
func getHandshakeHash(for peerID: PeerID) -> Data? {
return getSession(for: peerID)?.getHandshakeHash()
}
// MARK: - Session Rekeying // MARK: - Session Rekeying
func getSessionsNeedingRekey() -> [(peerID: PeerID, needsRekey: Bool)] { func getSessionsNeedingRekey() -> [(peerID: PeerID, needsRekey: Bool)] {
+85
View File
@@ -0,0 +1,85 @@
//
// 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 var 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
}
func setSessionStartTimeForTesting(_ date: Date) {
sessionStartTime = date
}
#endif
}
+22
View File
@@ -0,0 +1,22 @@
import Foundation
enum Base64URLCoding {
static func encode(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
static func decode(_ string: String) -> Data? {
var base64 = string
let padding = (4 - (base64.count % 4)) % 4
if padding > 0 {
base64 += String(repeating: "=", count: padding)
}
base64 = base64
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
return Data(base64Encoded: base64)
}
}
+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
}
}
+359 -71
View File
@@ -1,28 +1,161 @@
import BitLogger import BitLogger
import Foundation import Foundation
import Tor import Tor
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing. /// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
struct GeoRelayDirectoryDependencies {
var userDefaults: UserDefaults
var notificationCenter: NotificationCenter
var now: () -> Date
var remoteURL: URL
var fetchInterval: TimeInterval
var refreshCheckInterval: TimeInterval
var retryInitialSeconds: TimeInterval
var retryMaxSeconds: TimeInterval
var awaitTorReady: @Sendable () async -> Bool
var makeFetchData: @MainActor @Sendable () -> (@Sendable (URLRequest) async throws -> Data)
var readData: (URL) -> Data?
var writeData: (Data, URL) throws -> Void
var cacheURL: () -> URL?
var bundledCSVURLs: () -> [URL]
var currentDirectoryPath: () -> String?
var retrySleep: (TimeInterval) async -> Void
var activeNotificationName: Notification.Name?
var autoStart: Bool
}
private extension GeoRelayDirectoryDependencies {
@MainActor
static func live() -> Self {
#if os(iOS)
let activeNotificationName: Notification.Name? = UIApplication.didBecomeActiveNotification
#elseif os(macOS)
let activeNotificationName: Notification.Name? = NSApplication.didBecomeActiveNotification
#else
let activeNotificationName: Notification.Name? = nil
#endif
return Self(
userDefaults: .standard,
notificationCenter: .default,
now: Date.init,
remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!,
fetchInterval: TransportConfig.geoRelayFetchIntervalSeconds,
refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,
retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,
retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds,
awaitTorReady: { await TorManager.shared.awaitReady() },
makeFetchData: {
let session = TorURLSession.shared.session
return { request in
let (data, _) = try await session.data(for: request)
return data
}
},
readData: { try? Data(contentsOf: $0) },
writeData: { data, url in
try data.write(to: url, options: .atomic)
},
cacheURL: {
do {
let base = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir.appendingPathComponent("georelays_cache.csv")
} catch {
return nil
}
},
bundledCSVURLs: {
[
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
].compactMap { $0 }
},
currentDirectoryPath: { FileManager.default.currentDirectoryPath },
retrySleep: { delay in
let nanoseconds = UInt64(delay * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanoseconds)
},
activeNotificationName: activeNotificationName,
autoStart: true
)
}
}
@MainActor @MainActor
final class GeoRelayDirectory { final class GeoRelayDirectory {
struct Entry: Hashable { private final class CleanupState {
let notificationCenter: NotificationCenter
var observers: [NSObjectProtocol] = []
var refreshTimer: Timer?
var retryTask: Task<Void, Never>?
init(notificationCenter: NotificationCenter) {
self.notificationCenter = notificationCenter
}
deinit {
observers.forEach { notificationCenter.removeObserver($0) }
refreshTimer?.invalidate()
retryTask?.cancel()
}
}
struct Entry: Hashable, Sendable {
let host: String let host: String
let lat: Double let lat: Double
let lon: Double let lon: Double
} }
private enum DetachedFetchOutcome: Sendable {
case success(entries: [Entry], csv: String)
case torNotReady
case invalidData
case network(String)
}
static let shared = GeoRelayDirectory() static let shared = GeoRelayDirectory()
private(set) var entries: [Entry] = [] private(set) var entries: [Entry] = []
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 dependencies: GeoRelayDirectoryDependencies
private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds // 24h private let cleanupState: CleanupState
private var retryAttempt: Int = 0
private var isFetching: Bool = false
private init() { private init() {
// Load cached or bundled data synchronously self.dependencies = .live()
self.entries = self.loadLocalEntries() self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter)
// Fire-and-forget remote refresh if stale entries = loadLocalEntries()
prefetchIfNeeded() if dependencies.autoStart {
registerObservers()
startRefreshTimer()
prefetchIfNeeded()
}
}
internal init(dependencies: GeoRelayDirectoryDependencies) {
self.dependencies = dependencies
self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter)
entries = loadLocalEntries()
if dependencies.autoStart {
registerObservers()
startRefreshTimer()
prefetchIfNeeded()
}
} }
/// Returns up to `count` relay URLs (wss://) closest to the geohash center. /// Returns up to `count` relay URLs (wss://) closest to the geohash center.
@@ -33,56 +166,176 @@ 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
func prefetchIfNeeded() { func prefetchIfNeeded(force: Bool = false) {
let now = Date() guard !isFetching else { return }
let last = UserDefaults.standard.object(forKey: lastFetchKey) as? Date ?? .distantPast
guard now.timeIntervalSince(last) >= fetchInterval else { return } let now = dependencies.now()
let last = dependencies.userDefaults.object(forKey: lastFetchKey) as? Date ?? .distantPast
if !force {
guard now.timeIntervalSince(last) >= dependencies.fetchInterval else { return }
} else if last != .distantPast,
now.timeIntervalSince(last) < dependencies.retryInitialSeconds {
// Skip forced fetches if we just refreshed moments ago.
return
}
cancelRetry()
fetchRemote() fetchRemote()
} }
private func fetchRemote() { private func fetchRemote() {
let req = URLRequest(url: remoteURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15) guard !isFetching else { return }
// Ensure Tor readiness before fetching (fail-closed by default) isFetching = true
Task.detached {
let ready = await TorManager.shared.awaitReady() let request = URLRequest(
if !ready { url: dependencies.remoteURL,
SecureLogger.warning("GeoRelayDirectory: Tor not ready; skipping remote fetch (fail-closed)", category: .session) cachePolicy: .reloadIgnoringLocalCacheData,
return timeoutInterval: 15
)
let awaitTorReady = dependencies.awaitTorReady
let fetchData = dependencies.makeFetchData()
Task { [weak self] in
guard let self else { return }
let outcome = await Self.fetchRemoteOutcome(
request: request,
awaitTorReady: awaitTorReady,
fetchData: fetchData
)
switch outcome {
case .success(let parsed, let csv):
self.handleFetchSuccess(entries: parsed, csv: csv)
case .torNotReady:
self.handleFetchFailure(.torNotReady)
case .invalidData:
self.handleFetchFailure(.invalidData)
case .network(let description):
self.handleFetchFailure(.network(description))
} }
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()
} }
} }
nonisolated private static func fetchRemoteOutcome(
request: URLRequest,
awaitTorReady: @escaping @Sendable () async -> Bool,
fetchData: @escaping @Sendable (URLRequest) async throws -> Data
) async -> DetachedFetchOutcome {
await Task.detached(priority: .utility) {
let ready = await awaitTorReady()
guard ready else { return .torNotReady }
do {
let data = try await fetchData(request)
guard let text = String(data: data, encoding: .utf8) else {
return .invalidData
}
let parsed = Self.parseCSV(text)
guard !parsed.isEmpty else {
return .invalidData
}
return .success(entries: parsed, csv: text)
} catch {
return .network(error.localizedDescription)
}
}.value
}
private enum FetchFailure {
case torNotReady
case invalidData
case network(String)
}
@MainActor
private func handleFetchSuccess(entries parsed: [Entry], csv: String) {
entries = parsed
persistCache(csv)
dependencies.userDefaults.set(dependencies.now(), forKey: lastFetchKey)
SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
isFetching = false
retryAttempt = 0
cancelRetry()
}
@MainActor
private func handleFetchFailure(_ reason: FetchFailure) {
switch reason {
case .torNotReady:
SecureLogger.warning("GeoRelayDirectory: Tor not ready; scheduling retry", category: .session)
case .invalidData:
SecureLogger.warning("GeoRelayDirectory: remote fetch returned invalid data; scheduling retry", category: .session)
case .network(let errorDescription):
SecureLogger.warning("GeoRelayDirectory: remote fetch failed with error: \(errorDescription)", category: .session)
}
isFetching = false
scheduleRetry()
}
@MainActor
private func scheduleRetry() {
retryAttempt = min(retryAttempt + 1, 10)
let base = dependencies.retryInitialSeconds
let maxDelay = dependencies.retryMaxSeconds
let multiplier = pow(2.0, Double(max(retryAttempt - 1, 0)))
let calculated = base * multiplier
let delay = min(maxDelay, max(base, calculated))
cancelRetry()
cleanupState.retryTask = Task { [weak self] in
guard let self else { return }
await self.dependencies.retrySleep(delay)
guard !Task.isCancelled else { return }
await MainActor.run {
self.prefetchIfNeeded(force: true)
}
}
}
@MainActor
private func cancelRetry() {
cleanupState.retryTask?.cancel()
cleanupState.retryTask = nil
}
private func persistCache(_ text: String) { private func persistCache(_ text: String) {
guard let url = cacheURL() else { return } guard let url = dependencies.cacheURL() else { return }
guard let data = text.data(using: .utf8) else { return }
do { do {
try text.data(using: .utf8)?.write(to: url, options: .atomic) try dependencies.writeData(data, url)
} catch { } catch {
SecureLogger.warning("GeoRelayDirectory: failed to write cache: \(error)", category: .session) SecureLogger.warning("GeoRelayDirectory: failed to write cache: \(error)", category: .session)
} }
@@ -91,30 +344,31 @@ final class GeoRelayDirectory {
// MARK: - Loading // MARK: - Loading
private func loadLocalEntries() -> [Entry] { private func loadLocalEntries() -> [Entry] {
// Prefer cached file if present // Prefer cached file if present
if let cache = self.cacheURL(), if let cache = dependencies.cacheURL(),
let data = try? Data(contentsOf: cache), let data = dependencies.readData(cache),
let text = String(data: data, encoding: .utf8) { let text = String(data: data, encoding: .utf8) {
let arr = Self.parseCSV(text) let arr = Self.parseCSV(text)
if !arr.isEmpty { return arr } if !arr.isEmpty { return arr }
} }
// Try bundled resource(s) // Try bundled resource(s)
let bundleCandidates = [ let bundleCandidates = dependencies.bundledCSVURLs()
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
].compactMap { $0 }
for url in bundleCandidates { for url in bundleCandidates {
if let data = try? Data(contentsOf: url), let text = String(data: data, encoding: .utf8) { if let data = dependencies.readData(url),
let text = String(data: data, encoding: .utf8) {
let arr = Self.parseCSV(text) let arr = Self.parseCSV(text)
if !arr.isEmpty { return arr } if !arr.isEmpty { return arr }
} }
} }
// Try filesystem path (development/test) // Try filesystem path (development/test)
if let cwd = FileManager.default.currentDirectoryPath as String?, if let cwd = dependencies.currentDirectoryPath(),
let data = try? Data(contentsOf: URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")), let data = dependencies.readData(URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
let text = String(data: data, encoding: .utf8) { let text = String(data: data, encoding: .utf8) {
return Self.parseCSV(text) return Self.parseCSV(text)
} }
SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session) SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session)
return [] return []
} }
@@ -122,33 +376,67 @@ final class GeoRelayDirectory {
nonisolated static func parseCSV(_ text: String) -> [Entry] { nonisolated static func parseCSV(_ text: String) -> [Entry] {
var result: Set<Entry> = [] var result: Set<Entry> = []
let lines = text.split(whereSeparator: { $0.isNewline }) let lines = text.split(whereSeparator: { $0.isNewline })
// Skip header if present
for (idx, raw) in lines.enumerated() { for (idx, raw) in lines.enumerated() {
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard let line = raw.trimmedOrNilIfEmpty else { continue }
if line.isEmpty { continue }
if idx == 0 && line.lowercased().contains("relay url") { continue } if idx == 0 && line.lowercased().contains("relay url") { continue }
let parts = line.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } let parts = line.split(separator: ",").map { $0.trimmed }
guard parts.count >= 3 else { continue } guard parts.count >= 3 else { continue }
var host = parts[0] guard let host = NostrRelayURL.directoryAddress(parts[0]) else { continue }
host = host.replacingOccurrences(of: "https://", with: "")
host = host.replacingOccurrences(of: "http://", with: "")
host = host.replacingOccurrences(of: "wss://", with: "")
host = host.replacingOccurrences(of: "ws://", with: "")
host = host.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
guard let lat = Double(parts[1]), let lon = Double(parts[2]) else { continue } guard let lat = Double(parts[1]), let lon = Double(parts[2]) else { continue }
result.insert(Entry(host: host, lat: lat, lon: lon)) result.insert(Entry(host: host, lat: lat, lon: lon))
} }
return Array(result) return Array(result)
} }
private func cacheURL() -> URL? { // MARK: - Observers & Timers
do { private func registerObservers() {
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) let center = dependencies.notificationCenter
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) let torReady = center.addObserver(
return dir.appendingPathComponent(cacheFileName) forName: .TorDidBecomeReady,
} catch { return nil } object: nil,
queue: .main
) { [weak self] _ in
guard let self else { return }
Task { @MainActor in
self.prefetchIfNeeded(force: true)
}
}
cleanupState.observers.append(torReady)
if let activeNotificationName = dependencies.activeNotificationName {
let didBecomeActive = center.addObserver(
forName: activeNotificationName,
object: nil,
queue: .main
) { [weak self] _ in
guard let self else { return }
Task { @MainActor in
self.prefetchIfNeeded()
}
}
cleanupState.observers.append(didBecomeActive)
}
} }
private func startRefreshTimer() {
cleanupState.refreshTimer?.invalidate()
let interval = dependencies.refreshCheckInterval
guard interval > 0 else { return }
let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
guard let self else { return }
Task { @MainActor in
self.prefetchIfNeeded()
}
}
cleanupState.refreshTimer = timer
RunLoop.main.add(timer, forMode: .common)
}
var debugRetryAttempt: Int { retryAttempt }
var debugHasRetryTask: Bool { cleanupState.retryTask != nil }
var debugObserverCount: Int { cleanupState.observers.count }
} }
// MARK: - Distance // MARK: - Distance
+16 -15
View File
@@ -1,10 +1,11 @@
import Foundation import Foundation
import BitFoundation
// MARK: - BitChat-over-Nostr Adapter // MARK: - BitChat-over-Nostr Adapter
struct NostrEmbeddedBitChat { struct NostrEmbeddedBitChat {
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a private message for Nostr DMs. /// Build a `bitchat1:` base64url-encoded BitChat packet carrying a private message for Nostr DMs.
static func encodePMForNostr(content: String, messageID: String, recipientPeerID: String, senderPeerID: String) -> String? { static func encodePMForNostr(content: String, messageID: String, recipientPeerID: PeerID, senderPeerID: PeerID) -> String? {
// TLV-encode the private message // TLV-encode the private message
let pm = PrivateMessagePacket(messageID: messageID, content: content) let pm = PrivateMessagePacket(messageID: messageID, content: content)
guard let tlv = pm.encode() else { return nil } guard let tlv = pm.encode() else { return nil }
@@ -14,12 +15,12 @@ struct NostrEmbeddedBitChat {
payload.append(tlv) payload.append(tlv)
// Determine 8-byte recipient ID to embed // Determine 8-byte recipient ID to embed
let recipientIDHex: String = normalizeRecipientPeerID(recipientPeerID) let recipientID = normalizeRecipientPeerID(recipientPeerID)
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue, type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: senderPeerID) ?? Data(), senderID: Data(hexString: senderPeerID.id) ?? Data(),
recipientID: Data(hexString: recipientIDHex), recipientID: Data(hexString: recipientID.id),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload, payload: payload,
signature: nil, signature: nil,
@@ -31,18 +32,18 @@ struct NostrEmbeddedBitChat {
} }
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a delivery/read ack for Nostr DMs. /// Build a `bitchat1:` base64url-encoded BitChat packet carrying a delivery/read ack for Nostr DMs.
static func encodeAckForNostr(type: NoisePayloadType, messageID: String, recipientPeerID: String, senderPeerID: String) -> String? { static func encodeAckForNostr(type: NoisePayloadType, messageID: String, recipientPeerID: PeerID, senderPeerID: PeerID) -> String? {
guard type == .delivered || type == .readReceipt else { return nil } guard type == .delivered || type == .readReceipt else { return nil }
var payload = Data([type.rawValue]) var payload = Data([type.rawValue])
payload.append(Data(messageID.utf8)) payload.append(Data(messageID.utf8))
let recipientIDHex: String = normalizeRecipientPeerID(recipientPeerID) let recipientID = normalizeRecipientPeerID(recipientPeerID)
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue, type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: senderPeerID) ?? Data(), senderID: Data(hexString: senderPeerID.id) ?? Data(),
recipientID: Data(hexString: recipientIDHex), recipientID: Data(hexString: recipientID.id),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload, payload: payload,
signature: nil, signature: nil,
@@ -54,7 +55,7 @@ struct NostrEmbeddedBitChat {
} }
/// Build a `bitchat1:` ACK (delivered/read) without an embedded recipient peer ID (geohash DMs). /// Build a `bitchat1:` ACK (delivered/read) without an embedded recipient peer ID (geohash DMs).
static func encodeAckForNostrNoRecipient(type: NoisePayloadType, messageID: String, senderPeerID: String) -> String? { static func encodeAckForNostrNoRecipient(type: NoisePayloadType, messageID: String, senderPeerID: PeerID) -> String? {
guard type == .delivered || type == .readReceipt else { return nil } guard type == .delivered || type == .readReceipt else { return nil }
var payload = Data([type.rawValue]) var payload = Data([type.rawValue])
@@ -62,7 +63,7 @@ struct NostrEmbeddedBitChat {
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue, type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: senderPeerID) ?? Data(), senderID: Data(hexString: senderPeerID.id) ?? Data(),
recipientID: nil, recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload, payload: payload,
@@ -75,7 +76,7 @@ struct NostrEmbeddedBitChat {
} }
/// Build a `bitchat1:` payload without an embedded recipient peer ID (used for geohash DMs). /// Build a `bitchat1:` payload without an embedded recipient peer ID (used for geohash DMs).
static func encodePMForNostrNoRecipient(content: String, messageID: String, senderPeerID: String) -> String? { static func encodePMForNostrNoRecipient(content: String, messageID: String, senderPeerID: PeerID) -> String? {
let pm = PrivateMessagePacket(messageID: messageID, content: content) let pm = PrivateMessagePacket(messageID: messageID, content: content)
guard let tlv = pm.encode() else { return nil } guard let tlv = pm.encode() else { return nil }
@@ -84,7 +85,7 @@ struct NostrEmbeddedBitChat {
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue, type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: senderPeerID) ?? Data(), senderID: Data(hexString: senderPeerID.id) ?? Data(),
recipientID: nil, recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload, payload: payload,
@@ -96,11 +97,11 @@ struct NostrEmbeddedBitChat {
return "bitchat1:" + base64URLEncode(data) return "bitchat1:" + base64URLEncode(data)
} }
private static func normalizeRecipientPeerID(_ recipientPeerID: String) -> String { private static func normalizeRecipientPeerID(_ recipientPeerID: PeerID) -> PeerID {
if let maybeData = Data(hexString: recipientPeerID) { if let maybeData = Data(hexString: recipientPeerID.id) {
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 PeerID(publicKey: maybeData).id return PeerID(publicKey: maybeData)
} 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
-308
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,266 +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() {
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 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
let fallback = (seed + msg).sha256Hash()
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
+158
View File
@@ -0,0 +1,158 @@
import BitFoundation
import Foundation
import CryptoKit
/// Bridge between Noise and Nostr identities
final class NostrIdentityBridge {
private let keychainService = "chat.bitchat.nostr"
private let currentIdentityKey = "nostr-current-identity"
private let deviceSeedKey = "nostr-device-seed"
// In-memory cache to avoid transient keychain access issues
private var deviceSeedCache: Data?
// Cache derived identities to avoid repeated crypto during view rendering
private var derivedIdentityCache: [String: NostrIdentity] = [:]
private let cacheLock = NSLock()
private let keychain: KeychainManagerProtocol
init(keychain: KeychainManagerProtocol = KeychainManager()) {
self.keychain = keychain
}
/// Get or create the current Nostr identity
func getCurrentNostrIdentity() throws -> NostrIdentity? {
// Check if we already have a Nostr identity
if let existingData = keychain.load(key: currentIdentityKey, service: keychainService),
let identity = try? JSONDecoder().decode(NostrIdentity.self, from: existingData) {
return identity
}
// Generate new Nostr identity
let nostrIdentity = try NostrIdentity.generate()
// Store it
let data = try JSONEncoder().encode(nostrIdentity)
keychain.save(key: currentIdentityKey, data: data, service: keychainService, accessible: nil)
return nostrIdentity
}
/// Associate a Nostr identity with a Noise public key (for favorites)
func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
if let data = nostrPubkey.data(using: .utf8) {
keychain.save(key: key, data: data, service: keychainService, accessible: nil)
}
}
/// Get Nostr public key associated with a Noise public key
func getNostrPublicKey(for noisePublicKey: Data) -> String? {
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
guard let data = keychain.load(key: key, service: keychainService),
let pubkey = String(data: data, encoding: .utf8) else {
return nil
}
return pubkey
}
/// Clear all Nostr identity associations and current identity
func clearAllAssociations() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecMatchLimit as String: kSecMatchLimitAll,
kSecReturnAttributes as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecSuccess, let items = result as? [[String: Any]] {
for item in items {
var deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService
]
if let account = item[kSecAttrAccount as String] as? String {
deleteQuery[kSecAttrAccount as String] = account
}
SecItemDelete(deleteQuery as CFDictionary)
}
} else if status == errSecItemNotFound {
// nothing persisted; no action needed
}
deviceSeedCache = nil
}
// MARK: - Per-Geohash Identities (Location Channels)
/// Returns a stable device seed used to derive unlinkable per-geohash identities.
/// Stored only on device keychain.
private func getOrCreateDeviceSeed() -> Data {
if let cached = deviceSeedCache { return cached }
if let existing = keychain.load(key: deviceSeedKey, service: keychainService) {
// Migrate to AfterFirstUnlockThisDeviceOnly for stability during lock
keychain.save(key: deviceSeedKey, data: existing, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
deviceSeedCache = existing
return existing
}
var seed = Data(count: 32)
_ = seed.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
}
// Ensure availability after first unlock to prevent unintended rotation when locked
keychain.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
deviceSeedCache = seed
return seed
}
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
/// if the candidate is not a valid secp256k1 private key.
func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
// Check cache first to avoid repeated crypto + keychain I/O during view rendering
cacheLock.lock()
if let cached = derivedIdentityCache[geohash] {
cacheLock.unlock()
return cached
}
cacheLock.unlock()
let seed = getOrCreateDeviceSeed()
guard let msg = geohash.data(using: .utf8) else {
throw NSError(domain: "NostrIdentity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid geohash string"])
}
func candidateKey(iteration: UInt32) -> Data {
var input = Data(msg)
var iterBE = iteration.bigEndian
withUnsafeBytes(of: &iterBE) { bytes in
input.append(contentsOf: bytes)
}
let code = HMAC<SHA256>.authenticationCode(for: input, using: SymmetricKey(data: seed))
return Data(code)
}
// Try a few iterations to ensure a valid key can be formed
for i in 0..<10 {
let keyData = candidateKey(iteration: UInt32(i))
if let identity = try? NostrIdentity(privateKeyData: keyData) {
// Cache the result
cacheLock.lock()
derivedIdentityCache[geohash] = identity
cacheLock.unlock()
return identity
}
}
// As a final fallback, hash the seed+msg and try again
let fallback = (seed + msg).sha256Hash()
let identity = try NostrIdentity(privateKeyData: fallback)
// Cache the result
cacheLock.lock()
derivedIdentityCache[geohash] = identity
cacheLock.unlock()
return identity
}
}
+44 -20
View File
@@ -18,6 +18,7 @@ struct NostrProtocol {
case seal = 13 // NIP-17 sealed event case seal = 13 // NIP-17 sealed event
case giftWrap = 1059 // NIP-59 gift wrap case giftWrap = 1059 // NIP-59 gift wrap
case ephemeralEvent = 20000 case ephemeralEvent = 20000
case geohashPresence = 20001
} }
/// Create a NIP-17 private message /// Create a NIP-17 private message
@@ -108,7 +109,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?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty { if let nickname = nickname?.trimmedOrNilIfEmpty {
tags.append(["n", nickname]) tags.append(["n", nickname])
} }
if teleported { if teleported {
@@ -125,6 +126,24 @@ struct NostrProtocol {
return try event.sign(with: schnorrKey) return try event.sign(with: schnorrKey)
} }
/// Create a geohash presence heartbeat (kind 20001)
/// Must contain empty content and NO nickname tag
static func createGeohashPresenceEvent(
geohash: String,
senderIdentity: NostrIdentity
) throws -> NostrEvent {
let tags = [["g", geohash]]
let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .geohashPresence,
tags: tags,
content: ""
)
let schnorrKey = try senderIdentity.schnorrSigningKey()
return try event.sign(with: schnorrKey)
}
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash. /// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
static func createGeohashTextNote( static func createGeohashTextNote(
content: String, content: String,
@@ -133,7 +152,7 @@ struct NostrProtocol {
nickname: String? = nil nickname: String? = nil
) throws -> NostrEvent { ) throws -> NostrEvent {
var tags = [["g", geohash]] var tags = [["g", geohash]]
if let nickname = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty { if let nickname = nickname?.trimmedOrNilIfEmpty {
tags.append(["n", nickname]) tags.append(["n", nickname])
} }
let event = NostrEvent( let event = NostrEvent(
@@ -284,7 +303,7 @@ struct NostrProtocol {
combined.append(nonce24) combined.append(nonce24)
combined.append(sealed.ciphertext) combined.append(sealed.ciphertext)
combined.append(sealed.tag) combined.append(sealed.tag)
return "v2:" + base64URLEncode(combined) return "v2:" + Base64URLCoding.encode(combined)
} }
private static func decrypt( private static func decrypt(
@@ -295,7 +314,7 @@ struct NostrProtocol {
// Expect NIP-44 v2 format // Expect NIP-44 v2 format
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext } guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
let encoded = String(ciphertext.dropFirst(3)) let encoded = String(ciphertext.dropFirst(3))
guard let data = base64URLDecode(encoded), guard let data = Base64URLCoding.decode(encoded),
data.count > (24 + 16), data.count > (24 + 16),
let senderPubkeyData = Data(hexString: senderPubkey) else { let senderPubkeyData = Data(hexString: senderPubkey) else {
throw NostrError.invalidCiphertext throw NostrError.invalidCiphertext
@@ -509,6 +528,26 @@ struct NostrEvent: Codable {
signed.sig = signatureHex signed.sig = signatureHex
return signed return signed
} }
/// Validate that the event ID and Schnorr signature match the content and pubkey.
/// Returns false when the signature is missing, malformed, or does not verify.
func isValidSignature() -> Bool {
guard let sig = sig,
let sigData = Data(hexString: sig),
let pubData = Data(hexString: pubkey),
sigData.count == 64,
pubData.count == 32,
let signature = try? P256K.Schnorr.SchnorrSignature(dataRepresentation: sigData),
let (expectedId, eventHash) = try? calculateEventId(),
expectedId == id
else {
return false
}
var messageBytes = [UInt8](eventHash)
let xonly = P256K.Schnorr.XonlyKey(dataRepresentation: pubData)
return xonly.isValid(signature, for: &messageBytes)
}
private func calculateEventId() throws -> (String, Data) { private func calculateEventId() throws -> (String, Data) {
let serialized = [ let serialized = [
@@ -541,24 +580,9 @@ enum NostrError: Error {
case encryptionFailed case encryptionFailed
} }
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305 + base64url) // MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
private extension NostrProtocol { private extension NostrProtocol {
static func base64URLEncode(_ data: Data) -> String {
return data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
static func base64URLDecode(_ s: String) -> Data? {
var str = s
let pad = (4 - (str.count % 4)) % 4
if pad > 0 { str += String(repeating: "=", count: pad) }
str = str.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
return Data(base64Encoded: str)
}
static func deriveNIP44V2Key(from sharedSecretData: Data) throws -> Data { static func deriveNIP44V2Key(from sharedSecretData: Data) throws -> Data {
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey( let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: sharedSecretData), inputKeyMaterial: SymmetricKey(data: sharedSecretData),
+354 -136
View File
@@ -4,6 +4,100 @@ import Network
import Combine import Combine
import Tor import Tor
protocol NostrRelayConnectionProtocol: AnyObject {
func resume()
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?)
func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void)
func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
func sendPing(pongReceiveHandler: @escaping (Error?) -> Void)
}
protocol NostrRelaySessionProtocol {
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol
}
private final class URLSessionWebSocketTaskAdapter: NostrRelayConnectionProtocol {
private let base: URLSessionWebSocketTask
init(base: URLSessionWebSocketTask) {
self.base = base
}
func resume() {
base.resume()
}
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
base.cancel(with: closeCode, reason: reason)
}
func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void) {
base.send(message, completionHandler: completionHandler)
}
func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
base.receive(completionHandler: completionHandler)
}
func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) {
base.sendPing(pongReceiveHandler: pongReceiveHandler)
}
}
private struct URLSessionAdapter: NostrRelaySessionProtocol {
let base: URLSession
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol {
URLSessionWebSocketTaskAdapter(base: base.webSocketTask(with: url))
}
}
struct NostrRelayManagerDependencies {
var activationAllowed: () -> Bool
var userTorEnabled: () -> Bool
var hasMutualFavorites: () -> Bool
var hasLocationPermission: () -> Bool
var mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>
var locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>
var torEnforced: () -> Bool
var torIsReady: () -> Bool
var torIsForeground: () -> Bool
var awaitTorReady: (@escaping (Bool) -> Void) -> Void
var makeSession: () -> NostrRelaySessionProtocol
var scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void
var now: () -> Date
}
private extension NostrRelayManagerDependencies {
@MainActor
static func live() -> Self {
Self(
activationAllowed: { NetworkActivationService.shared.activationAllowed },
userTorEnabled: { NetworkActivationService.shared.userTorEnabled },
hasMutualFavorites: { !FavoritesPersistenceService.shared.mutualFavorites.isEmpty },
hasLocationPermission: { LocationChannelManager.shared.permissionState == .authorized },
mutualFavoritesPublisher: FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher(),
locationPermissionPublisher: LocationChannelManager.shared.$permissionState.eraseToAnyPublisher(),
torEnforced: { TorManager.shared.torEnforced },
torIsReady: { TorManager.shared.isReady },
torIsForeground: { TorManager.shared.isForeground() },
awaitTorReady: { completion in
Task.detached {
let ready = await TorManager.shared.awaitReady()
await MainActor.run {
completion(ready)
}
}
},
makeSession: { URLSessionAdapter(base: TorURLSession.shared.session) },
scheduleAfter: { delay, action in
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
},
now: Date.init
)
}
}
/// Manages WebSocket connections to Nostr relays /// Manages WebSocket connections to Nostr relays
@MainActor @MainActor
final class NostrRelayManager: ObservableObject { final class NostrRelayManager: ObservableObject {
@@ -27,31 +121,40 @@ final class NostrRelayManager: ObservableObject {
var nextReconnectTime: Date? var nextReconnectTime: Date?
} }
// Default relay list (can be customized) // Default relays carry NIP-17 gift wraps, so avoid relays known to reject kind 1059.
private static let defaultRelays = [ private static let defaultRelays = [
"wss://relay.damus.io", "wss://relay.damus.io",
"wss://nos.lol", "wss://nos.lol",
"wss://relay.primal.net", "wss://relay.primal.net",
"wss://offchain.pub", "wss://offchain.pub"
"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) private static let defaultRelaySet = Set(defaultRelays.compactMap { NostrRelayURL.normalized($0) })
@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 let dependencies: NostrRelayManagerDependencies
private var allowDefaultRelays: Bool = false private var allowDefaultRelays: Bool = false
private var hasMutualFavorites: Bool = false private var hasMutualFavorites: Bool = false
private var hasLocationPermission: Bool = false private var hasLocationPermission: Bool = false
private var connections: [String: URLSessionWebSocketTask] = [:] private var connections: [String: NostrRelayConnectionProtocol] = [:]
private var subscriptions: [String: Set<String>] = [:] // relay URL -> active 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 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 // Coalesce duplicate subscribe requests for the same id within a short window.
private let subscribeCoalesceInterval: TimeInterval = 1.0
private var subscribeCoalesce: [String: Date] = [:] private var subscribeCoalesce: [String: Date] = [:]
private var pendingTorConnectionURLs = Set<String>()
private var awaitingTorForConnections = false
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private struct SubscriptionRequestState: Equatable {
let messageString: String
let relayURLs: Set<String>
}
private var subscriptionRequestState: [String: SubscriptionRequestState] = [:]
// Track EOSE per subscription to signal when initial stored events are done // Track EOSE per subscription to signal when initial stored events are done
private struct EOSETracker { private struct EOSETracker {
var pendingRelays: Set<String> var pendingRelays: Set<String>
@@ -59,6 +162,7 @@ final class NostrRelayManager: ObservableObject {
var timer: Timer? var timer: Timer?
} }
private var eoseTrackers: [String: EOSETracker] = [:] private var eoseTrackers: [String: EOSETracker] = [:]
private var pendingEOSECallbacks: [String: () -> Void] = [:]
// Message queue for reliability // Message queue for reliability
// Pending sends held only for relays that are not yet connected. // Pending sends held only for relays that are not yet connected.
@@ -69,9 +173,7 @@ final class NostrRelayManager: ObservableObject {
private var messageQueue: [PendingSend] = [] private var messageQueue: [PendingSend] = []
private let messageQueueLock = NSLock() private let messageQueueLock = NSLock()
private let encoder = JSONEncoder() private let encoder = JSONEncoder()
private let decoder = JSONDecoder() private var shouldUseTor: Bool { dependencies.userTorEnabled() }
private var networkService: NetworkActivationService { NetworkActivationService.shared }
private var shouldUseTor: Bool { networkService.userTorEnabled }
// Exponential backoff configuration // Exponential backoff configuration
private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
@@ -79,18 +181,17 @@ final class NostrRelayManager: ObservableObject {
private let backoffMultiplier: Double = TransportConfig.nostrRelayBackoffMultiplier private let backoffMultiplier: Double = TransportConfig.nostrRelayBackoffMultiplier
private let maxReconnectAttempts = TransportConfig.nostrRelayMaxReconnectAttempts private let maxReconnectAttempts = TransportConfig.nostrRelayMaxReconnectAttempts
// Reconnection timer
private var reconnectionTimer: Timer?
// Bump generation to invalidate scheduled reconnects when we reset/disconnect // Bump generation to invalidate scheduled reconnects when we reset/disconnect
private var connectionGeneration: Int = 0 private var connectionGeneration: Int = 0
init() { init() {
hasMutualFavorites = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty self.dependencies = .live()
hasLocationPermission = LocationChannelManager.shared.permissionState == .authorized hasMutualFavorites = dependencies.hasMutualFavorites()
hasLocationPermission = dependencies.hasLocationPermission()
applyDefaultRelayPolicy(force: true) applyDefaultRelayPolicy(force: true)
// Deterministic JSON shape for outbound requests // Deterministic JSON shape for outbound requests
self.encoder.outputFormatting = .sortedKeys self.encoder.outputFormatting = .sortedKeys
FavoritesPersistenceService.shared.$mutualFavorites dependencies.mutualFavoritesPublisher
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] favorites in .sink { [weak self] favorites in
guard let self = self else { return } guard let self = self else { return }
@@ -98,7 +199,34 @@ final class NostrRelayManager: ObservableObject {
self.applyDefaultRelayPolicy() self.applyDefaultRelayPolicy()
} }
.store(in: &cancellables) .store(in: &cancellables)
LocationChannelManager.shared.$permissionState dependencies.locationPermissionPublisher
.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)
}
internal init(dependencies: NostrRelayManagerDependencies) {
self.dependencies = dependencies
hasMutualFavorites = dependencies.hasMutualFavorites()
hasLocationPermission = dependencies.hasLocationPermission()
applyDefaultRelayPolicy(force: true)
// Deterministic JSON shape for outbound requests
self.encoder.outputFormatting = .sortedKeys
dependencies.mutualFavoritesPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] favorites in
guard let self = self else { return }
self.hasMutualFavorites = !favorites.isEmpty
self.applyDefaultRelayPolicy()
}
.store(in: &cancellables)
dependencies.locationPermissionPublisher
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] state in .sink { [weak self] state in
guard let self = self else { return } guard let self = self else { return }
@@ -113,28 +241,8 @@ final class NostrRelayManager: ObservableObject {
/// Connect to all configured relays /// Connect to all configured relays
func connect() { func connect() {
// Global network policy gate // Global network policy gate
guard networkService.activationAllowed else { return } guard dependencies.activationAllowed() else { return }
if shouldUseTor { connectToRelays(relays.map(\.url), shouldLog: true)
// 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
@@ -147,44 +255,40 @@ final class NostrRelayManager: ObservableObject {
// Clear known subscriptions and any queued subs since connections are gone // Clear known subscriptions and any queued subs since connections are gone
subscriptions.removeAll() subscriptions.removeAll()
pendingSubscriptions.removeAll() pendingSubscriptions.removeAll()
subscriptionRequestState.removeAll()
pendingEOSECallbacks.removeAll()
for (_, tracker) in eoseTrackers {
tracker.timer?.invalidate()
}
eoseTrackers.removeAll()
pendingTorConnectionURLs.removeAll()
awaitingTorForConnections = false
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]) {
// Global network policy gate // Global network policy gate
guard networkService.activationAllowed else { return } guard dependencies.activationAllowed() else { return }
let targets = allowedRelayList(from: relayUrls) let targets = allowedRelayList(from: relayUrls)
guard !targets.isEmpty else { return } guard !targets.isEmpty else { return }
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
// Defer until Tor is fully ready; avoid queuing connection attempts early
Task.detached { [weak self] in
guard let self = self else { return }
let ready = await TorManager.shared.awaitReady()
await MainActor.run { if ready { self.ensureConnections(to: relayUrls) } }
}
return
}
var existing = Set(relays.map { $0.url }) var existing = Set(relays.map { $0.url })
for url in targets where !existing.contains(url) { for url in targets where !existing.contains(url) {
relays.append(Relay(url: url)) relays.append(Relay(url: url))
existing.insert(url) existing.insert(url)
} }
for url in targets where connections[url] == nil { connectToRelays(targets)
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) {
// Global network policy gate // Global network policy gate
guard networkService.activationAllowed else { return } guard dependencies.activationAllowed() else { return }
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady { if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
// Defer sends until Tor is ready to avoid premature queueing // Defer sends until Tor is ready to avoid premature queueing
Task.detached { [weak self] in dependencies.awaitTorReady { [weak self] ready in
guard let self = self else { return } guard let self = self else { return }
let ready = await TorManager.shared.awaitReady() if ready { self.sendEvent(event, to: relayUrls) }
await MainActor.run { if ready { self.sendEvent(event, to: relayUrls) } }
} }
return return
} }
@@ -196,7 +300,7 @@ final class NostrRelayManager: ObservableObject {
// Attempt immediate send to relays with active connections; queue the rest // Attempt immediate send to relays with active connections; queue the rest
var stillPending = Set<String>() var stillPending = Set<String>()
for relayUrl in targetRelays { for relayUrl in targetRelays {
if let connection = connections[relayUrl] { if let connection = connectedConnection(for: relayUrl) {
sendToRelay(event: event, connection: connection, relayUrl: relayUrl) sendToRelay(event: event, connection: connection, relayUrl: relayUrl)
} else { } else {
stillPending.insert(relayUrl) stillPending.insert(relayUrl)
@@ -218,7 +322,7 @@ final class NostrRelayManager: ObservableObject {
// Flush only for a specific relay // Flush only for a specific relay
for i in (0..<messageQueue.count).reversed() { for i in (0..<messageQueue.count).reversed() {
var item = messageQueue[i] var item = messageQueue[i]
if item.pendingRelays.contains(target), let conn = connections[target] { if item.pendingRelays.contains(target), let conn = connectedConnection(for: target) {
sendToRelay(event: item.event, connection: conn, relayUrl: target) sendToRelay(event: item.event, connection: conn, relayUrl: target)
item.pendingRelays.remove(target) item.pendingRelays.remove(target)
if item.pendingRelays.isEmpty { if item.pendingRelays.isEmpty {
@@ -233,7 +337,7 @@ final class NostrRelayManager: ObservableObject {
for i in (0..<messageQueue.count).reversed() { for i in (0..<messageQueue.count).reversed() {
var item = messageQueue[i] var item = messageQueue[i]
for url in item.pendingRelays { for url in item.pendingRelays {
if let conn = connections[url] { if let conn = connectedConnection(for: url) {
sendToRelay(event: item.event, connection: conn, relayUrl: url) sendToRelay(event: item.event, connection: conn, relayUrl: url)
item.pendingRelays.remove(url) item.pendingRelays.remove(url)
} }
@@ -246,6 +350,14 @@ final class NostrRelayManager: ObservableObject {
} }
} }
} }
private func connectedConnection(for relayUrl: String) -> NostrRelayConnectionProtocol? {
guard let connection = connections[relayUrl],
relays.first(where: { $0.url == relayUrl })?.isConnected == true else {
return nil
}
return connection
}
/// Subscribe to events matching a filter. If `relayUrls` provided, targets only those relays. /// Subscribe to events matching a filter. If `relayUrls` provided, targets only those relays.
func subscribe( func subscribe(
@@ -256,28 +368,13 @@ final class NostrRelayManager: ObservableObject {
onEOSE: (() -> Void)? = nil onEOSE: (() -> Void)? = nil
) { ) {
// Global network policy gate // Global network policy gate
guard networkService.activationAllowed else { return } guard dependencies.activationAllowed() else { return }
// Coalesce rapid duplicate subscribe requests only if a handler already exists // Coalesce rapid duplicate subscribe requests even while Tor readiness is pending.
let now = Date() let now = dependencies.now()
if messageHandlers[id] != nil { if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < subscribeCoalesceInterval {
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 return
} }
subscribeCoalesce[id] = now
messageHandlers[id] = handler messageHandlers[id] = handler
let req = NostrRequest.subscribe(id: id, filters: [filter]) let req = NostrRequest.subscribe(id: id, filters: [filter])
@@ -293,14 +390,20 @@ final class NostrRelayManager: ObservableObject {
// Target specific relays if provided; else default. Filter permanently failed relays. // Target specific relays if provided; else default. Filter permanently failed relays.
let baseUrls = relayUrls ?? Self.defaultRelays let baseUrls = relayUrls ?? Self.defaultRelays
let candidateUrls = baseUrls.filter { !isPermanentlyFailed($0) } let urls = allowedRelayList(from: baseUrls).filter { !isPermanentlyFailed($0) }
let urls = allowedRelayList(from: candidateUrls) let requestState = SubscriptionRequestState(messageString: messageString, relayURLs: Set(urls))
if subscriptionRequestState[id] == requestState, subscriptionStateExists(id: id, requestState: requestState) {
return
}
subscriptionRequestState[id] = requestState
// Always queue subscriptions; sending happens when a relay reports connected // Always queue subscriptions; sending happens when a relay reports connected
let existingSet = Set(relays.map { $0.url }) var existingSet = Set(relays.map { $0.url })
for url in urls where !existingSet.contains(url) { for url in urls where !existingSet.contains(url) {
relays.append(Relay(url: url)) relays.append(Relay(url: url))
existingSet.insert(url)
} }
for url in candidateUrls { for url in urls {
var map = self.pendingSubscriptions[url] ?? [:] var map = self.pendingSubscriptions[url] ?? [:]
map[id] = messageString map[id] = messageString
self.pendingSubscriptions[url] = map self.pendingSubscriptions[url] = map
@@ -309,20 +412,10 @@ final class NostrRelayManager: ObservableObject {
if let onEOSE = onEOSE { if let onEOSE = onEOSE {
if urls.isEmpty { if urls.isEmpty {
onEOSE() onEOSE()
} else if shouldWaitForTorBeforeConnecting {
pendingEOSECallbacks[id] = onEOSE
} else { } else {
var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil) startEOSETracking(id: id, relayURLs: Set(urls), callback: onEOSE)
// 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
guard let self = self else { return }
if let t = self.eoseTrackers[id] {
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) SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session)
@@ -349,7 +442,7 @@ final class NostrRelayManager: ObservableObject {
relays.append(Relay(url: url)) relays.append(Relay(url: url))
existing.insert(url) existing.insert(url)
} }
if networkService.activationAllowed { if dependencies.activationAllowed() {
ensureConnections(to: Self.defaultRelays) ensureConnections(to: Self.defaultRelays)
} }
} else { } else {
@@ -359,6 +452,7 @@ final class NostrRelayManager: ObservableObject {
} }
connections.removeValue(forKey: url) connections.removeValue(forKey: url)
subscriptions.removeValue(forKey: url) subscriptions.removeValue(forKey: url)
pendingSubscriptions.removeValue(forKey: url)
} }
messageQueueLock.lock() messageQueueLock.lock()
for index in (0..<messageQueue.count).reversed() { for index in (0..<messageQueue.count).reversed() {
@@ -379,7 +473,8 @@ final class NostrRelayManager: ObservableObject {
private func allowedRelayList(from urls: [String]) -> [String] { private func allowedRelayList(from urls: [String]) -> [String] {
var seen = Set<String>() var seen = Set<String>()
var result: [String] = [] var result: [String] = []
for url in urls { for rawURL in urls {
guard let url = NostrRelayURL.normalized(rawURL) else { continue }
if !allowDefaultRelays && Self.defaultRelaySet.contains(url) { continue } if !allowDefaultRelays && Self.defaultRelaySet.contains(url) { continue }
if seen.insert(url).inserted { if seen.insert(url).inserted {
result.append(url) result.append(url)
@@ -393,6 +488,13 @@ final class NostrRelayManager: ObservableObject {
messageHandlers.removeValue(forKey: id) messageHandlers.removeValue(forKey: id)
// Allow immediate re-subscription by clearing coalescer timestamp // Allow immediate re-subscription by clearing coalescer timestamp
subscribeCoalesce.removeValue(forKey: id) subscribeCoalesce.removeValue(forKey: id)
subscriptionRequestState.removeValue(forKey: id)
pendingEOSECallbacks.removeValue(forKey: id)
eoseTrackers[id]?.timer?.invalidate()
eoseTrackers.removeValue(forKey: id)
for url in Array(pendingSubscriptions.keys) {
pendingSubscriptions[url]?.removeValue(forKey: id)
}
let req = NostrRequest.close(id: id) let req = NostrRequest.close(id: id)
let message = try? encoder.encode(req) let message = try? encoder.encode(req)
@@ -403,27 +505,120 @@ final class NostrRelayManager: ObservableObject {
// Send unsubscribe to all relays // Send unsubscribe to all relays
for (relayUrl, connection) in connections { for (relayUrl, connection) in connections {
if subscriptions[relayUrl]?.contains(id) == true { if subscriptions[relayUrl]?.contains(id) == true {
subscriptions[relayUrl]?.remove(id)
connection.send(.string(messageString)) { _ in connection.send(.string(messageString)) { _ in
Task { @MainActor in // Local state is cleared before sending so callers can re-subscribe immediately.
self.subscriptions[relayUrl]?.remove(id)
}
} }
} }
} }
} }
// MARK: - Private Methods // MARK: - Private Methods
private var shouldWaitForTorBeforeConnecting: Bool {
shouldUseTor && !dependencies.torIsReady()
}
private func connectToRelays(_ relayUrls: [String], shouldLog: Bool = false) {
guard dependencies.activationAllowed() else { return }
let targets = allowedRelayList(from: relayUrls).filter {
connections[$0] == nil && !isPermanentlyFailed($0)
}
guard !targets.isEmpty else { return }
if shouldWaitForTorBeforeConnecting {
queueConnectionsUntilTorReady(targets)
return
}
if shouldLog {
let route = shouldUseTor ? "via Tor" : "direct"
SecureLogger.debug("🌐 Connecting to \(targets.count) Nostr relay(s) (\(route))", category: .session)
}
for url in targets {
connectToRelay(url)
}
}
private func queueConnectionsUntilTorReady(_ relayUrls: [String]) {
let targets = allowedRelayList(from: relayUrls).filter {
connections[$0] == nil && !isPermanentlyFailed($0)
}
guard !targets.isEmpty else { return }
pendingTorConnectionURLs.formUnion(targets)
guard !awaitingTorForConnections else { return }
awaitingTorForConnections = true
let generation = connectionGeneration
dependencies.awaitTorReady { [weak self] ready in
guard let self else { return }
guard generation == self.connectionGeneration else { return }
let pending = Array(self.pendingTorConnectionURLs)
self.pendingTorConnectionURLs.removeAll()
self.awaitingTorForConnections = false
guard ready else {
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
return
}
self.connectToRelays(pending, shouldLog: true)
}
}
private func subscriptionStateExists(id: String, requestState: SubscriptionRequestState) -> Bool {
guard !requestState.relayURLs.isEmpty else { return true }
return requestState.relayURLs.allSatisfy { url in
pendingSubscriptions[url]?[id] == requestState.messageString ||
subscriptions[url]?.contains(id) == true
}
}
private func startEOSETracking(id: String, relayURLs: Set<String>, callback: @escaping () -> Void) {
eoseTrackers[id]?.timer?.invalidate()
var tracker = EOSETracker(pendingRelays: relayURLs, callback: callback, 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
guard let self else { return }
if let tracker = self.eoseTrackers[id] {
tracker.timer?.invalidate()
self.eoseTrackers.removeValue(forKey: id)
callback()
}
}
}
eoseTrackers[id] = tracker
}
private func startPendingEOSETrackingIfNeeded(id: String) {
guard eoseTrackers[id] == nil,
let callback = pendingEOSECallbacks.removeValue(forKey: id),
let requestState = subscriptionRequestState[id]
else {
return
}
if requestState.relayURLs.isEmpty {
callback()
} else {
startEOSETracking(id: id, relayURLs: requestState.relayURLs, callback: callback)
}
}
private func connectToRelay(_ urlString: String) { private func connectToRelay(_ urlString: String) {
// Global network policy gate // Global network policy gate
guard networkService.activationAllowed else { return } guard dependencies.activationAllowed() else { return }
guard let url = URL(string: urlString) else { guard let url = URL(string: urlString) else {
SecureLogger.warning("Invalid relay URL: \(urlString)", category: .session) SecureLogger.warning("Invalid relay URL: \(urlString)", category: .session)
return return
} }
// Avoid initiating connections while app is backgrounded; we'll reconnect on foreground // Avoid initiating connections while app is backgrounded; we'll reconnect on foreground
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isForeground() { if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsForeground() {
return return
} }
@@ -438,19 +633,12 @@ final class NostrRelayManager: ObservableObject {
// Attempting to connect to Nostr relay via the proxied session // Attempting to connect to Nostr relay via the proxied session
// If Tor is enforced but not ready, delay connection until it is. // If Tor is enforced but not ready, delay connection until it is.
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady { if shouldWaitForTorBeforeConnecting {
Task.detached { [weak self] in queueConnectionsUntilTorReady([urlString])
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 return
} }
let session = TorURLSession.shared.session let session = dependencies.makeSession()
let task = session.webSocketTask(with: url) let task = session.webSocketTask(with: url)
connections[urlString] = task connections[urlString] = task
@@ -483,6 +671,7 @@ final class NostrRelayManager: ObservableObject {
guard let connection = connections[relayUrl] else { return } guard let connection = connections[relayUrl] else { return }
for (id, messageString) in map { for (id, messageString) in map {
if self.subscriptions[relayUrl]?.contains(id) == true { continue } if self.subscriptions[relayUrl]?.contains(id) == true { continue }
startPendingEOSETrackingIfNeeded(id: id)
connection.send(.string(messageString)) { error in connection.send(.string(messageString)) { error in
if let error = error { if let error = error {
SecureLogger.error("❌ Failed to send pending subscription to \(relayUrl): \(error)", category: .session) SecureLogger.error("❌ Failed to send pending subscription to \(relayUrl): \(error)", category: .session)
@@ -498,7 +687,7 @@ final class NostrRelayManager: ObservableObject {
pendingSubscriptions[relayUrl] = nil pendingSubscriptions[relayUrl] = nil
} }
private func receiveMessage(from task: URLSessionWebSocketTask, relayUrl: String) { private func receiveMessage(from task: NostrRelayConnectionProtocol, relayUrl: String) {
task.receive { [weak self] result in task.receive { [weak self] result in
guard let self = self else { return } guard let self = self else { return }
@@ -508,7 +697,7 @@ final class NostrRelayManager: ObservableObject {
Task.detached(priority: .utility) { Task.detached(priority: .utility) {
guard let parsed = ParsedInbound(message) else { return } guard let parsed = ParsedInbound(message) else { return }
await MainActor.run { await MainActor.run {
NostrRelayManager.shared.handleParsedMessage(parsed, from: relayUrl) self.handleParsedMessage(parsed, from: relayUrl)
} }
} }
@@ -562,9 +751,9 @@ final class NostrRelayManager: ObservableObject {
} else { } else {
let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil
if isGiftWrap { if isGiftWrap {
SecureLogger.warning("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)", category: .session) SecureLogger.warning("📮 Rejected id=\(eventId.prefix(16)) relay=\(relayUrl) reason=\(reason)", category: .session)
} else { } else {
SecureLogger.error("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)", category: .session) SecureLogger.error("📮 Rejected id=\(eventId.prefix(16)) relay=\(relayUrl) reason=\(reason)", category: .session)
} }
} }
case .notice: case .notice:
@@ -572,7 +761,7 @@ final class NostrRelayManager: ObservableObject {
} }
} }
private func sendToRelay(event: NostrEvent, connection: URLSessionWebSocketTask, relayUrl: String) { private func sendToRelay(event: NostrEvent, connection: NostrRelayConnectionProtocol, relayUrl: String) {
let req = NostrRequest.event(event) let req = NostrRequest.event(event)
do { do {
@@ -604,11 +793,11 @@ final class NostrRelayManager: ObservableObject {
relays[index].isConnected = isConnected relays[index].isConnected = isConnected
relays[index].lastError = error relays[index].lastError = error
if isConnected { if isConnected {
relays[index].lastConnectedAt = Date() relays[index].lastConnectedAt = dependencies.now()
relays[index].reconnectAttempts = 0 // Reset on successful connection relays[index].reconnectAttempts = 0 // Reset on successful connection
relays[index].nextReconnectTime = nil relays[index].nextReconnectTime = nil
} else { } else {
relays[index].lastDisconnectedAt = Date() relays[index].lastDisconnectedAt = dependencies.now()
} }
} }
updateConnectionStatus() updateConnectionStatus()
@@ -624,7 +813,7 @@ final 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 networking is disallowed, do not schedule reconnection
if !networkService.activationAllowed { if !dependencies.activationAllowed() {
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)
@@ -669,19 +858,21 @@ final class NostrRelayManager: ObservableObject {
maxBackoffInterval maxBackoffInterval
) )
let nextReconnectTime = Date().addingTimeInterval(backoffInterval) let nextReconnectTime = dependencies.now().addingTimeInterval(backoffInterval)
relays[index].nextReconnectTime = nextReconnectTime relays[index].nextReconnectTime = nextReconnectTime
// Schedule reconnection with exponential backoff // Schedule reconnection with exponential backoff
let gen = connectionGeneration let gen = connectionGeneration
DispatchQueue.main.asyncAfter(deadline: .now() + backoffInterval) { [weak self] in dependencies.scheduleAfter(backoffInterval) { [weak self] in
guard let self = self else { return } Task { @MainActor [weak self] in
// Ignore stale scheduled reconnects from a previous generation guard let self = self else { return }
guard gen == self.connectionGeneration else { return } // Ignore stale scheduled reconnects from a previous generation
// Check if we should still reconnect (relay might have been removed) guard gen == self.connectionGeneration else { return }
if self.relays.contains(where: { $0.url == relayUrl }) { // Check if we should still reconnect (relay might have been removed)
self.connectToRelay(relayUrl) if self.relays.contains(where: { $0.url == relayUrl }) {
self.connectToRelay(relayUrl)
}
} }
} }
} }
@@ -690,20 +881,22 @@ final class NostrRelayManager: ObservableObject {
/// Manually retry connection to a specific relay /// Manually retry connection to a specific relay
func retryConnection(to relayUrl: String) { func retryConnection(to relayUrl: String) {
guard let index = relays.firstIndex(where: { $0.url == relayUrl }) else { return } let normalizedRelayUrl = NostrRelayURL.normalized(relayUrl) ?? relayUrl
guard let index = relays.firstIndex(where: { $0.url == normalizedRelayUrl }) else { return }
// Reset reconnection attempts // Reset reconnection attempts
relays[index].reconnectAttempts = 0 relays[index].reconnectAttempts = 0
relays[index].nextReconnectTime = nil relays[index].nextReconnectTime = nil
relays[index].lastError = nil
// Disconnect if connected // Disconnect if connected
if let connection = connections[relayUrl] { if let connection = connections[normalizedRelayUrl] {
connection.cancel(with: .goingAway, reason: nil) connection.cancel(with: .goingAway, reason: nil)
connections.removeValue(forKey: relayUrl) connections.removeValue(forKey: normalizedRelayUrl)
} }
// Attempt immediate reconnection // Attempt immediate reconnection
connectToRelay(relayUrl) connectToRelay(normalizedRelayUrl)
} }
/// Get detailed status for all relays /// Get detailed status for all relays
@@ -715,6 +908,20 @@ final class NostrRelayManager: ObservableObject {
nextReconnectTime: relay.nextReconnectTime) nextReconnectTime: relay.nextReconnectTime)
} }
} }
var debugPendingMessageQueueCount: Int {
messageQueueLock.lock()
defer { messageQueueLock.unlock() }
return messageQueue.count
}
func debugPendingSubscriptionCount(for relayUrl: String) -> Int {
pendingSubscriptions[relayUrl]?.count ?? 0
}
func debugFlushMessageQueue() {
flushMessageQueue(for: nil)
}
/// Reset all relay connections /// Reset all relay connections
func resetAllConnections() { func resetAllConnections() {
@@ -767,7 +974,8 @@ private enum ParsedInbound {
if array.count >= 3, if array.count >= 3,
let subId = array[1] as? String, let subId = array[1] as? String,
let eventDict = array[2] as? [String: Any], let eventDict = array[2] as? [String: Any],
let event = try? NostrEvent(from: eventDict) { let event = try? NostrEvent(from: eventDict),
event.isValidSignature() {
self = .event(subId: subId, event: event) self = .event(subId: subId, event: event)
return return
} }
@@ -887,10 +1095,10 @@ struct NostrFilter: Encodable {
return filter return filter
} }
// For location channels: geohash-scoped ephemeral events (kind 20000) // For location channels: geohash-scoped ephemeral events (kind 20000) and presence (kind 20001)
static func geohashEphemeral(_ geohash: String, since: Date? = nil, limit: Int = 200) -> NostrFilter { static func geohashEphemeral(_ geohash: String, since: Date? = nil, limit: Int = 1000) -> NostrFilter {
var filter = NostrFilter() var filter = NostrFilter()
filter.kinds = [20000] filter.kinds = [20000, 20001]
filter.since = since?.timeIntervalSince1970.toInt() filter.since = since?.timeIntervalSince1970.toInt()
filter.tagFilters = ["g": [geohash]] filter.tagFilters = ["g": [geohash]]
filter.limit = limit filter.limit = limit
@@ -906,6 +1114,16 @@ struct NostrFilter: Encodable {
filter.limit = limit filter.limit = limit
return filter 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
+51
View File
@@ -0,0 +1,51 @@
import Foundation
enum NostrRelayURL {
static func normalized(_ rawValue: String, defaultScheme: String? = nil) -> String? {
var value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { return nil }
if !value.contains("://"), let defaultScheme {
value = "\(defaultScheme)://\(value)"
}
guard var components = URLComponents(string: value),
let rawScheme = components.scheme?.lowercased(),
let rawHost = components.host?.lowercased(),
!rawHost.isEmpty else {
return nil
}
switch rawScheme {
case "wss", "https":
components.scheme = "wss"
if components.port == 443 {
components.port = nil
}
case "ws", "http":
components.scheme = "ws"
if components.port == 80 {
components.port = nil
}
default:
return nil
}
components.host = rawHost
if components.path == "/" {
components.path = ""
}
components.fragment = nil
return components.string
}
static func directoryAddress(_ rawValue: String) -> String? {
guard var normalized = normalized(rawValue, defaultScheme: "wss") else { return nil }
for prefix in ["wss://", "ws://"] where normalized.hasPrefix(prefix) {
normalized.removeFirst(prefix.count)
break
}
return normalized
}
}
+28 -9
View File
@@ -5,16 +5,27 @@ import CryptoKit
/// Implements HChaCha20 to derive a subkey and reduces the 24-byte nonce to a 12-byte nonce /// Implements HChaCha20 to derive a subkey and reduces the 24-byte nonce to a 12-byte nonce
/// as per XChaCha20 construction. /// as per XChaCha20 construction.
enum XChaCha20Poly1305Compat { enum XChaCha20Poly1305Compat {
/// Errors that can occur during XChaCha20-Poly1305 operations
enum Error: Swift.Error {
case invalidKeyLength(expected: Int, got: Int)
case invalidNonceLength(expected: Int, got: Int)
}
struct SealBox { struct SealBox {
let ciphertext: Data let ciphertext: Data
let tag: Data let tag: Data
} }
static func seal(plaintext: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> SealBox { static func seal(plaintext: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> SealBox {
precondition(key.count == 32, "XChaCha20 key must be 32 bytes") guard key.count == 32 else {
precondition(nonce24.count == 24, "XChaCha20 nonce must be 24 bytes") throw Error.invalidKeyLength(expected: 32, got: key.count)
}
guard nonce24.count == 24 else {
throw Error.invalidNonceLength(expected: 24, got: nonce24.count)
}
let subkey = hchacha20(key: key, nonce16: nonce24.prefix(16)) let subkey = try hchacha20(key: key, nonce16: Data(nonce24.prefix(16)))
let nonce12 = derive12ByteNonce(from24: nonce24) let nonce12 = derive12ByteNonce(from24: nonce24)
let chachaKey = SymmetricKey(data: subkey) let chachaKey = SymmetricKey(data: subkey)
let nonce = try ChaChaPoly.Nonce(data: nonce12) let nonce = try ChaChaPoly.Nonce(data: nonce12)
@@ -23,10 +34,14 @@ enum XChaCha20Poly1305Compat {
} }
static func open(ciphertext: Data, tag: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> Data { static func open(ciphertext: Data, tag: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> Data {
precondition(key.count == 32, "XChaCha20 key must be 32 bytes") guard key.count == 32 else {
precondition(nonce24.count == 24, "XChaCha20 nonce must be 24 bytes") throw Error.invalidKeyLength(expected: 32, got: key.count)
}
guard nonce24.count == 24 else {
throw Error.invalidNonceLength(expected: 24, got: nonce24.count)
}
let subkey = hchacha20(key: key, nonce16: nonce24.prefix(16)) let subkey = try hchacha20(key: key, nonce16: Data(nonce24.prefix(16)))
let nonce12 = derive12ByteNonce(from24: nonce24) let nonce12 = derive12ByteNonce(from24: nonce24)
let chachaKey = SymmetricKey(data: subkey) let chachaKey = SymmetricKey(data: subkey)
let box = try ChaChaPoly.SealedBox(nonce: ChaChaPoly.Nonce(data: nonce12), ciphertext: ciphertext, tag: tag) let box = try ChaChaPoly.SealedBox(nonce: ChaChaPoly.Nonce(data: nonce12), ciphertext: ciphertext, tag: tag)
@@ -43,10 +58,14 @@ enum XChaCha20Poly1305Compat {
return out return out
} }
private static func hchacha20(key: Data, nonce16: Data) -> Data { private static func hchacha20(key: Data, nonce16: Data) throws -> Data {
// HChaCha20 based on the original ChaCha20 core with a 16-byte nonce. // HChaCha20 based on the original ChaCha20 core with a 16-byte nonce.
precondition(key.count == 32) guard key.count == 32 else {
precondition(nonce16.count == 16) throw Error.invalidKeyLength(expected: 32, got: key.count)
}
guard nonce16.count == 16 else {
throw Error.invalidNonceLength(expected: 16, got: nonce16.count)
}
// Constants "expand 32-byte k" // Constants "expand 32-byte k"
var state: [UInt32] = [ var state: [UInt32] = [
-330
View File
@@ -1,330 +0,0 @@
//
// BinaryProtocol.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
///
/// # BinaryProtocol
///
/// Low-level binary encoding and decoding for BitChat protocol messages.
/// Optimized for Bluetooth LE's limited bandwidth and MTU constraints.
///
/// ## Overview
/// BinaryProtocol implements an efficient binary wire format that minimizes
/// overhead while maintaining extensibility. It handles:
/// - Compact binary encoding with fixed headers
/// - Optional field support via flags
/// - Automatic compression for large payloads
/// - Endianness handling for cross-platform compatibility
///
/// ## Wire Format
/// ```
/// Header (Fixed 13 bytes):
/// +--------+------+-----+-----------+-------+----------------+
/// |Version | Type | TTL | Timestamp | Flags | PayloadLength |
/// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 bytes |
/// +--------+------+-----+-----------+-------+----------------+
///
/// Variable sections:
/// +----------+-------------+---------+------------+
/// | SenderID | RecipientID | Payload | Signature |
/// | 8 bytes | 8 bytes* | Variable| 64 bytes* |
/// +----------+-------------+---------+------------+
/// * Optional fields based on flags
/// ```
///
/// ## Design Rationale
/// The protocol is designed for:
/// - **Efficiency**: Minimal overhead for small messages
/// - **Flexibility**: Optional fields via flag bits
/// - **Compatibility**: Network byte order (big-endian)
/// - **Performance**: Zero-copy where possible
///
/// ## Compression Strategy
/// - Automatic compression for payloads > 256 bytes
/// - zlib compression for broad compatibility on Apple platforms
/// - Original size stored for decompression
/// - Flag bit indicates compressed payload
///
/// ## Flag Bits
/// - Bit 0: Has recipient ID (directed message)
/// - Bit 1: Has signature (authenticated message)
/// - Bit 2: Is compressed (LZ4 compression applied)
/// - Bits 3-7: Reserved for future use
///
/// ## Size Constraints
/// - Maximum packet size: 65,535 bytes (16-bit length field)
/// - Typical packet size: < 512 bytes (BLE MTU)
/// - Minimum packet size: 21 bytes (header + sender ID)
///
/// ## Encoding Process
/// 1. Construct header with fixed fields
/// 2. Set appropriate flags
/// 3. Compress payload if beneficial
/// 4. Append variable-length fields
/// 5. Calculate and append signature if needed
///
/// ## Decoding Process
/// 1. Validate minimum packet size
/// 2. Parse fixed header
/// 3. Extract flags and determine field presence
/// 4. Parse variable fields based on flags
/// 5. Decompress payload if compressed
/// 6. Verify signature if present
///
/// ## Error Handling
/// - Graceful handling of malformed packets
/// - Clear error messages for debugging
/// - No crashes on invalid input
/// - Logging of protocol violations
///
/// ## Performance Notes
/// - Allocation-free for small messages
/// - Streaming support for large payloads
/// - Efficient bit manipulation
/// - Platform-optimized byte swapping
///
import Foundation
extension Data {
func trimmingNullBytes() -> Data {
// Find the first null byte
if let nullIndex = self.firstIndex(of: 0) {
return self.prefix(nullIndex)
}
return self
}
}
/// Implements binary encoding and decoding for BitChat protocol messages.
/// Provides static methods for converting between BitchatPacket objects and
/// their binary wire format representation.
/// - Note: All multi-byte values use network byte order (big-endian)
struct BinaryProtocol {
static let headerSize = 13
static let senderIDSize = 8
static let recipientIDSize = 8
static let signatureSize = 64
struct Flags {
static let hasRecipient: UInt8 = 0x01
static let hasSignature: UInt8 = 0x02
static let isCompressed: UInt8 = 0x04
}
// Encode BitchatPacket to binary format
static func encode(_ packet: BitchatPacket, padding: Bool = true) -> Data? {
var data = Data()
// Try to compress payload if beneficial
var payload = packet.payload
var originalPayloadSize: UInt16? = nil
var isCompressed = false
if CompressionUtil.shouldCompress(payload) {
if let compressedPayload = CompressionUtil.compress(payload) {
// Store original size for decompression (2 bytes after payload)
originalPayloadSize = UInt16(payload.count)
payload = compressedPayload
isCompressed = true
} else {
}
} else {
}
// 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.type)
data.append(packet.ttl)
// Timestamp (8 bytes, big-endian)
for i in (0..<8).reversed() {
data.append(UInt8((packet.timestamp >> (i * 8)) & 0xFF))
}
// Flags
var flags: UInt8 = 0
if packet.recipientID != nil {
flags |= Flags.hasRecipient
}
if packet.signature != nil {
flags |= Flags.hasSignature
}
if isCompressed {
flags |= Flags.isCompressed
}
data.append(flags)
// Payload length (2 bytes, big-endian) - includes original size if compressed
let payloadDataSize = payload.count + (isCompressed ? 2 : 0)
let payloadLength = UInt16(payloadDataSize)
data.append(UInt8((payloadLength >> 8) & 0xFF))
data.append(UInt8(payloadLength & 0xFF))
// SenderID (exactly 8 bytes)
let senderBytes = packet.senderID.prefix(senderIDSize)
data.append(senderBytes)
if senderBytes.count < senderIDSize {
data.append(Data(repeating: 0, count: senderIDSize - senderBytes.count))
}
// RecipientID (if present)
if let recipientID = packet.recipientID {
let recipientBytes = recipientID.prefix(recipientIDSize)
data.append(recipientBytes)
if recipientBytes.count < recipientIDSize {
data.append(Data(repeating: 0, count: recipientIDSize - recipientBytes.count))
}
}
// Payload (with original size prepended if compressed)
if isCompressed, let originalSize = originalPayloadSize {
// Prepend original size (2 bytes, big-endian)
data.append(UInt8((originalSize >> 8) & 0xFF))
data.append(UInt8(originalSize & 0xFF))
}
data.append(payload)
// Signature (if present)
if let signature = packet.signature {
data.append(signature.prefix(signatureSize))
}
// Apply padding to standard block sizes for traffic analysis resistance
if padding {
let optimalSize = MessagePadding.optimalBlockSize(for: data.count)
let paddedData = MessagePadding.pad(data, toSize: optimalSize)
return paddedData
} else {
// Caller explicitly requested no padding (e.g., BLE write path)
return data
}
}
// Decode binary data to BitchatPacket
static func decode(_ data: Data) -> BitchatPacket? {
// Try decode as-is first (robust when padding wasn't applied)
if let pkt = decodeCore(data) { return pkt }
// If that fails, try after removing padding
let unpadded = MessagePadding.unpad(data)
if unpadded as NSData === data as NSData { return nil }
return decodeCore(unpadded)
}
// Core decoding implementation used by decode(_:) with and without padding removal
private static func decodeCore(_ raw: Data) -> BitchatPacket? {
// Minimum size: header + senderID
guard raw.count >= headerSize + senderIDSize else { return nil }
return raw.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> BitchatPacket? in
guard let base = buf.baseAddress else { return nil }
var offset = 0
func require(_ n: Int) -> Bool { offset + n <= buf.count }
// Read single byte
func read8() -> UInt8? {
guard require(1) else { return nil }
let v = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self).pointee
offset += 1
return v
}
// Read big-endian 16-bit
func read16() -> UInt16? {
guard require(2) else { return nil }
let p = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
let v = (UInt16(p[0]) << 8) | UInt16(p[1])
offset += 2
return v
}
// Copy N bytes into Data
func readData(_ n: Int) -> Data? {
guard require(n) else { return nil }
let ptr = base.advanced(by: offset)
let d = Data(bytes: ptr, count: n)
offset += n
return d
}
// Version
guard let version = read8(), version == 1 else { return nil }
guard let type = read8() else { return nil }
guard let ttl = read8() else { return nil }
// Timestamp 8 bytes BE
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
guard let flags = read8() else { return nil }
let hasRecipient = (flags & Flags.hasRecipient) != 0
let hasSignature = (flags & Flags.hasSignature) != 0
let isCompressed = (flags & Flags.isCompressed) != 0
// Payload length
guard let payloadLen = read16(), payloadLen <= 65535 else { return nil }
// SenderID
guard let senderID = readData(senderIDSize) else { return nil }
// Recipient
var recipientID: Data? = nil
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
)
}
}
}
+156
View File
@@ -0,0 +1,156 @@
//
// BitchatFilePacket.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
import BitFoundation
import BitLogger
/// TLV payload for Bluetooth mesh file transfers (voice notes, images, generic files).
/// Mirrors the Android client specification to ensure cross-platform interoperability.
struct BitchatFilePacket {
var fileName: String?
var fileSize: UInt64?
var mimeType: String?
var content: Data
/// Canonical TLV tags defined by the Android implementation.
private enum TLVType: UInt8 {
case fileName = 0x01
case fileSize = 0x02
case mimeType = 0x03
case content = 0x04
}
/// Encodes the packet using v2 canonical TLVs (4-byte FILE_SIZE, 4-byte CONTENT length).
/// Returns `nil` when fields exceed protocol limits (e.g., content > UInt32.max).
func encode() -> Data? {
let resolvedSize = fileSize ?? UInt64(content.count)
guard resolvedSize <= UInt64(UInt32.max) else { return nil }
guard resolvedSize <= UInt64(FileTransferLimits.maxPayloadBytes) else { return nil }
guard content.count <= Int(UInt32.max) else { return nil }
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
var big = value.bigEndian
withUnsafeBytes(of: &big) { data.append(contentsOf: $0) }
}
var encoded = Data()
if let name = fileName, let nameData = name.data(using: .utf8), nameData.count <= Int(UInt16.max) {
encoded.append(TLVType.fileName.rawValue)
appendBE(UInt16(nameData.count), into: &encoded)
encoded.append(nameData)
}
encoded.append(TLVType.fileSize.rawValue)
appendBE(UInt16(4), into: &encoded)
appendBE(UInt32(resolvedSize), into: &encoded)
if let mime = mimeType, let mimeData = mime.data(using: .utf8), mimeData.count <= Int(UInt16.max) {
encoded.append(TLVType.mimeType.rawValue)
appendBE(UInt16(mimeData.count), into: &encoded)
encoded.append(mimeData)
}
encoded.append(TLVType.content.rawValue)
appendBE(UInt32(content.count), into: &encoded)
encoded.append(content)
return encoded
}
/// Decodes TLV payloads, tolerating legacy encodings (FILE_SIZE len=8, CONTENT len=2) when possible.
static func decode(_ data: Data) -> BitchatFilePacket? {
var cursor = data.startIndex
let end = data.endIndex
var fileName: String?
var fileSize: UInt64?
var mimeType: String?
var content = Data()
while cursor < end {
let typeRaw = data[cursor]
cursor = data.index(after: cursor)
guard cursor <= end else { return nil }
let tlvType = TLVType(rawValue: typeRaw)
func readBigEndianLength(bytes: Int) -> Int? {
guard data.distance(from: cursor, to: end) >= bytes else { return nil }
// Use UInt64 to prevent integer overflow during shift operations
var result: UInt64 = 0
for _ in 0..<bytes {
result = (result << 8) | UInt64(data[cursor])
cursor = data.index(after: cursor)
}
// Safely convert to Int with overflow check
guard result <= Int.max else { return nil }
return Int(result)
}
let length: Int?
if tlvType == .content {
let snapshot = cursor
let canonical = readBigEndianLength(bytes: 4)
if let canonical = canonical,
canonical <= data.distance(from: cursor, to: end) {
length = canonical
} else {
cursor = snapshot
length = readBigEndianLength(bytes: 2)
}
} else {
length = readBigEndianLength(bytes: 2)
}
guard let tlvLength = length, tlvLength >= 0 else { return nil }
guard data.distance(from: cursor, to: end) >= tlvLength else { return nil }
let valueStart = cursor
cursor = data.index(cursor, offsetBy: tlvLength)
let value = data[valueStart..<cursor]
switch tlvType {
case .fileName:
fileName = String(data: Data(value), encoding: .utf8)
case .fileSize:
if tlvLength == 4 || tlvLength == 8 {
var size: UInt64 = 0
for byte in value {
size = (size << 8) | UInt64(byte)
}
if size > UInt64(FileTransferLimits.maxPayloadBytes) {
return nil
}
fileSize = size
}
case .mimeType:
mimeType = String(data: Data(value), encoding: .utf8)
case .content:
let proposedSize = content.count + value.count
if proposedSize > FileTransferLimits.maxPayloadBytes {
return nil
}
content.append(contentsOf: value)
case nil:
continue
}
}
guard !content.isEmpty else { return nil }
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
return BitchatFilePacket(
fileName: fileName,
fileSize: fileSize ?? UInt64(content.count),
mimeType: mimeType,
content: content
)
}
}
+3 -65
View File
@@ -60,38 +60,7 @@
import Foundation import Foundation
import CoreBluetooth import CoreBluetooth
import BitFoundation
// MARK: - Message Types
/// Simplified BitChat protocol message types.
/// Reduced from 24 types to just 6 essential ones.
/// All private communication metadata (receipts, status) is embedded in noiseEncrypted payloads.
enum MessageType: UInt8 {
// Public messages (unencrypted)
case announce = 0x01 // "I'm here" with nickname
case message = 0x02 // Public chat message
case leave = 0x03 // "I'm leaving"
case requestSync = 0x21 // GCS filter-based sync request (local-only)
// Noise encryption
case noiseHandshake = 0x10 // Handshake (init or response determined by payload)
case noiseEncrypted = 0x11 // All encrypted payloads (messages, receipts, etc.)
// Fragmentation (simplified)
case fragment = 0x20 // Single fragment type for large messages
var description: String {
switch self {
case .announce: return "announce"
case .message: return "message"
case .leave: return "leave"
case .requestSync: return "requestSync"
case .noiseHandshake: return "noiseHandshake"
case .noiseEncrypted: return "noiseEncrypted"
case .fragment: return "fragment"
}
}
}
// MARK: - Noise Payload Types // MARK: - Noise Payload Types
@@ -129,38 +98,8 @@ enum LazyHandshakeState {
case failed(Error) // Handshake failed case failed(Error) // Handshake failed
} }
// MARK: - Delivery Status
// Delivery status for messages
enum DeliveryStatus: Codable, Equatable, Hashable {
case sending
case sent // Left our device
case delivered(to: String, at: Date) // Confirmed by recipient
case read(by: String, at: Date) // Seen by recipient
case failed(reason: String)
case partiallyDelivered(reached: Int, total: Int) // For rooms
var displayText: String {
switch self {
case .sending:
return "Sending..."
case .sent:
return "Sent"
case .delivered(let nickname, _):
return "Delivered to \(nickname)"
case .read(let nickname, _):
return "Read by \(nickname)"
case .failed(let reason):
return "Failed: \(reason)"
case .partiallyDelivered(let reached, let total):
return "Delivered to \(reached)/\(total)"
}
}
}
// MARK: - Delegate Protocol // MARK: - Delegate Protocol
@MainActor
protocol BitchatDelegate: AnyObject { protocol BitchatDelegate: AnyObject {
func didReceiveMessage(_ message: BitchatMessage) func didReceiveMessage(_ message: BitchatMessage)
func didConnectToPeer(_ peerID: PeerID) func didConnectToPeer(_ peerID: PeerID)
@@ -177,11 +116,10 @@ protocol BitchatDelegate: AnyObject {
// Bluetooth state updates for user notifications // Bluetooth state updates for user notifications
func didUpdateBluetoothState(_ state: CBManagerState) func didUpdateBluetoothState(_ state: CBManagerState)
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
} }
// Provide default implementation to make it effectively optional // Provide default implementation to make it effectively optional
@MainActor
extension BitchatDelegate { extension BitchatDelegate {
func isFavorite(fingerprint: String) -> Bool { func isFavorite(fingerprint: String) -> Bool {
return false return false
@@ -195,7 +133,7 @@ extension BitchatDelegate {
// Default empty implementation // Default empty implementation
} }
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) { func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
// Default empty implementation // Default empty implementation
} }
} }
+53
View File
@@ -119,4 +119,57 @@ enum Geohash {
} }
return (latInterval.0, latInterval.1, lonInterval.0, lonInterval.1) 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)
}
}
} }
+14
View File
@@ -116,4 +116,18 @@ enum ChannelID: Equatable, Codable {
case .location(let ch): return ch.geohash case .location(let ch): return ch.geohash
} }
} }
var isMesh: Bool {
switch self {
case .mesh: true
case .location: false
}
}
var isLocation: Bool {
switch self {
case .mesh: false
case .location: true
}
}
} }
+26 -1
View File
@@ -6,11 +6,13 @@ struct AnnouncementPacket {
let nickname: String let nickname: String
let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement) let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement)
let signingPublicKey: Data // Ed25519 public key for signing let signingPublicKey: Data // Ed25519 public key for signing
let directNeighbors: [Data]? // 8-byte peer IDs
private enum TLVType: UInt8 { private enum TLVType: UInt8 {
case nickname = 0x01 case nickname = 0x01
case noisePublicKey = 0x02 case noisePublicKey = 0x02
case signingPublicKey = 0x03 case signingPublicKey = 0x03
case directNeighbors = 0x04
} }
func encode() -> Data? { func encode() -> Data? {
@@ -35,6 +37,16 @@ struct AnnouncementPacket {
data.append(TLVType.signingPublicKey.rawValue) data.append(TLVType.signingPublicKey.rawValue)
data.append(UInt8(signingPublicKey.count)) data.append(UInt8(signingPublicKey.count))
data.append(signingPublicKey) data.append(signingPublicKey)
// TLV for direct neighbors (optional)
if let neighbors = directNeighbors, !neighbors.isEmpty {
let neighborsData = neighbors.prefix(10).reduce(Data()) { $0 + $1 }
if !neighborsData.isEmpty && neighborsData.count % 8 == 0 {
data.append(TLVType.directNeighbors.rawValue)
data.append(UInt8(neighborsData.count))
data.append(neighborsData)
}
}
return data return data
} }
@@ -44,6 +56,7 @@ struct AnnouncementPacket {
var nickname: String? var nickname: String?
var noisePublicKey: Data? var noisePublicKey: Data?
var signingPublicKey: Data? var signingPublicKey: Data?
var directNeighbors: [Data]?
while offset + 2 <= data.count { while offset + 2 <= data.count {
let typeRaw = data[offset] let typeRaw = data[offset]
@@ -63,6 +76,17 @@ struct AnnouncementPacket {
noisePublicKey = Data(value) noisePublicKey = Data(value)
case .signingPublicKey: case .signingPublicKey:
signingPublicKey = Data(value) signingPublicKey = Data(value)
case .directNeighbors:
if length > 0 && length % 8 == 0 {
var neighbors = [Data]()
let count = length / 8
for i in 0..<count {
let start = value.startIndex + i * 8
let end = start + 8
neighbors.append(Data(value[start..<end]))
}
directNeighbors = neighbors
}
} }
} else { } else {
// Unknown TLV; skip (tolerant decoder for forward compatibility) // Unknown TLV; skip (tolerant decoder for forward compatibility)
@@ -74,7 +98,8 @@ struct AnnouncementPacket {
return AnnouncementPacket( return AnnouncementPacket(
nickname: nickname, nickname: nickname,
noisePublicKey: noisePublicKey, noisePublicKey: noisePublicKey,
signingPublicKey: signingPublicKey signingPublicKey: signingPublicKey,
directNeighbors: directNeighbors
) )
} }
} }
@@ -0,0 +1,116 @@
import BitFoundation
import Foundation
struct BLEAnnouncePreflightAcceptance {
let announcement: AnnouncementPacket
let derivedPeerID: PeerID
}
enum BLEAnnouncePreflightRejection: Equatable {
case malformed
case senderMismatch(derivedPeerID: PeerID)
case selfAnnounce
case stale(ageSeconds: Double)
}
enum BLEAnnouncePreflightDecision {
case accept(BLEAnnouncePreflightAcceptance)
case reject(BLEAnnouncePreflightRejection)
}
enum BLEAnnouncePreflightPolicy {
static func evaluate(
packet: BitchatPacket,
from peerID: PeerID,
localPeerID: PeerID,
now: Date
) -> BLEAnnouncePreflightDecision {
guard let announcement = AnnouncementPacket.decode(from: packet.payload) else {
return .reject(.malformed)
}
let derivedPeerID = PeerID(publicKey: announcement.noisePublicKey)
guard derivedPeerID == peerID else {
return .reject(.senderMismatch(derivedPeerID: derivedPeerID))
}
guard peerID != localPeerID else {
return .reject(.selfAnnounce)
}
guard !BLEPacketFreshnessPolicy.isStale(timestampMilliseconds: packet.timestamp, now: now) else {
return .reject(.stale(ageSeconds: BLEPacketFreshnessPolicy.ageSeconds(
timestampMilliseconds: packet.timestamp,
now: now
)))
}
return .accept(BLEAnnouncePreflightAcceptance(
announcement: announcement,
derivedPeerID: derivedPeerID
))
}
}
enum BLEAnnounceTrustRejection: Equatable {
case missingSignature
case invalidSignature
case keyMismatch
}
enum BLEAnnounceTrustDecision: Equatable {
case verified
case reject(BLEAnnounceTrustRejection)
var isVerified: Bool {
self == .verified
}
}
enum BLEAnnounceTrustPolicy {
static func evaluate(
hasSignature: Bool,
signatureValid: Bool,
existingNoisePublicKey: Data?,
announcedNoisePublicKey: Data
) -> BLEAnnounceTrustDecision {
if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey {
return .reject(.keyMismatch)
}
guard hasSignature else {
return .reject(.missingSignature)
}
guard signatureValid else {
return .reject(.invalidSignature)
}
return .verified
}
}
struct BLEAnnounceResponsePlan: Equatable {
let shouldNotifyPeerConnected: Bool
let shouldScheduleInitialSync: Bool
let shouldSendAnnounceBack: Bool
let shouldScheduleAfterglow: Bool
}
enum BLEAnnounceResponsePolicy {
static func plan(
isDirectAnnounce: Bool,
isNewPeer: Bool,
isReconnectedPeer: Bool,
shouldSendAnnounceBack: Bool
) -> BLEAnnounceResponsePlan {
let shouldNotifyPeerConnected = isDirectAnnounce && (isNewPeer || isReconnectedPeer)
return BLEAnnounceResponsePlan(
shouldNotifyPeerConnected: shouldNotifyPeerConnected,
shouldScheduleInitialSync: shouldNotifyPeerConnected,
shouldSendAnnounceBack: shouldSendAnnounceBack,
shouldScheduleAfterglow: isNewPeer
)
}
}
@@ -0,0 +1,31 @@
import Foundation
struct BLEAnnounceThrottle {
private var lastSent: Date
private let normalMinimumInterval: TimeInterval
private let forcedMinimumInterval: TimeInterval
init(
lastSent: Date = .distantPast,
normalMinimumInterval: TimeInterval = TransportConfig.bleAnnounceMinInterval,
forcedMinimumInterval: TimeInterval = TransportConfig.bleForceAnnounceMinIntervalSeconds
) {
self.lastSent = lastSent
self.normalMinimumInterval = normalMinimumInterval
self.forcedMinimumInterval = forcedMinimumInterval
}
func elapsed(since now: Date) -> TimeInterval {
now.timeIntervalSince(lastSent)
}
mutating func shouldSend(force: Bool, now: Date) -> Bool {
let minimumInterval = force ? forcedMinimumInterval : normalMinimumInterval
guard elapsed(since: now) >= minimumInterval else {
return false
}
lastSent = now
return true
}
}
@@ -0,0 +1,271 @@
import Foundation
struct BLEConnectionCandidate<Peripheral> {
let peripheral: Peripheral
let peripheralID: String
let rssi: Int
let name: String
let isConnectable: Bool
let discoveredAt: Date
}
struct BLEExistingConnectionState {
let isConnecting: Bool
let isConnected: Bool
let lastConnectionAttempt: Date?
}
enum BLEPeripheralConnectionState {
case disconnected
case connecting
case connected
}
enum BLEDiscoveryDecision: Equatable {
case ignore
case queued
case scheduleRetry(after: TimeInterval)
case cancelStaleConnection
case connectNow
}
enum BLEConnectionQueueDecision<Peripheral> {
case none
case retryAfter(TimeInterval)
case connect(BLEConnectionCandidate<Peripheral>)
}
final class BLEConnectionScheduler<Peripheral> {
private let maxCentralLinks: Int
private let connectRateLimitInterval: TimeInterval
private let candidateCap: Int
private let weakLinkCooldownSeconds: TimeInterval
private let weakLinkRSSICutoff: Int
private let recentTimeoutWindowSeconds: TimeInterval
private let recentTimeoutCountThreshold: Int
private var lastGlobalConnectAttempt: Date = .distantPast
private var candidates: [BLEConnectionCandidate<Peripheral>] = []
private var failureCounts: [String: Int] = [:]
private var recentConnectTimeouts: [String: Date] = [:]
private var lastIsolatedAt: Date?
private let initialDynamicRSSIThreshold: Int
private(set) var dynamicRSSIThreshold: Int
var candidateCount: Int {
candidates.count
}
init(
maxCentralLinks: Int = TransportConfig.bleMaxCentralLinks,
connectRateLimitInterval: TimeInterval = TransportConfig.bleConnectRateLimitInterval,
candidateCap: Int = TransportConfig.bleConnectionCandidatesMax,
weakLinkCooldownSeconds: TimeInterval = TransportConfig.bleWeakLinkCooldownSeconds,
weakLinkRSSICutoff: Int = TransportConfig.bleWeakLinkRSSICutoff,
recentTimeoutWindowSeconds: TimeInterval = TransportConfig.bleRecentTimeoutWindowSeconds,
recentTimeoutCountThreshold: Int = TransportConfig.bleRecentTimeoutCountThreshold,
dynamicRSSIThreshold: Int = TransportConfig.bleDynamicRSSIThresholdDefault
) {
self.maxCentralLinks = maxCentralLinks
self.connectRateLimitInterval = connectRateLimitInterval
self.candidateCap = candidateCap
self.weakLinkCooldownSeconds = weakLinkCooldownSeconds
self.weakLinkRSSICutoff = weakLinkRSSICutoff
self.recentTimeoutWindowSeconds = recentTimeoutWindowSeconds
self.recentTimeoutCountThreshold = recentTimeoutCountThreshold
self.initialDynamicRSSIThreshold = dynamicRSSIThreshold
self.dynamicRSSIThreshold = dynamicRSSIThreshold
}
func handleDiscovery(
_ candidate: BLEConnectionCandidate<Peripheral>,
connectedOrConnectingCount: Int,
existingState: BLEExistingConnectionState?,
peripheralState: BLEPeripheralConnectionState,
now: Date
) -> BLEDiscoveryDecision {
guard candidate.isConnectable else { return .ignore }
if candidate.rssi <= dynamicRSSIThreshold {
enqueue(candidate)
return .queued
}
if connectedOrConnectingCount >= maxCentralLinks {
enqueue(candidate)
return .queued
}
if let retryDelay = rateLimitRetryDelay(now: now) {
enqueue(candidate)
return .scheduleRetry(after: retryDelay)
}
if let existingState {
if existingState.isConnected || existingState.isConnecting {
return .ignore
}
if let lastAttempt = existingState.lastConnectionAttempt,
now.timeIntervalSince(lastAttempt) < 2.0 {
return .ignore
}
}
if let lastTimeout = recentConnectTimeouts[candidate.peripheralID],
now.timeIntervalSince(lastTimeout) < 15 {
return .ignore
}
switch peripheralState {
case .disconnected:
return .connectNow
case .connecting, .connected:
return .cancelStaleConnection
}
}
func enqueue(_ candidate: BLEConnectionCandidate<Peripheral>) {
if let existingIndex = candidates.firstIndex(where: { $0.peripheralID == candidate.peripheralID }) {
candidates[existingIndex] = candidate
} else {
candidates.append(candidate)
}
candidates.sort {
if $0.rssi != $1.rssi { return $0.rssi > $1.rssi }
return $0.discoveredAt < $1.discoveredAt
}
if candidates.count > candidateCap {
candidates.removeLast(candidates.count - candidateCap)
}
}
func nextCandidate(
connectedOrConnectingCount: Int,
isAlreadyConnectingOrConnected: (String) -> Bool,
now: Date
) -> BLEConnectionQueueDecision<Peripheral> {
guard connectedOrConnectingCount < maxCentralLinks else { return .none }
if let retryDelay = rateLimitRetryDelay(now: now) {
return .retryAfter(retryDelay)
}
while !candidates.isEmpty {
candidates.sort { score($0, now: now) > score($1, now: now) }
let candidate = candidates.removeFirst()
guard candidate.isConnectable else { continue }
if let delay = weakLinkRetryDelay(for: candidate, now: now) {
enqueue(candidate)
return .retryAfter(delay)
}
if isAlreadyConnectingOrConnected(candidate.peripheralID) {
continue
}
return .connect(candidate)
}
return .none
}
func recordConnectionAttempt(at now: Date) {
lastGlobalConnectAttempt = now
}
func recordConnectionSuccess(peripheralID: String) {
failureCounts[peripheralID] = 0
recentConnectTimeouts.removeValue(forKey: peripheralID)
}
func recordConnectionFailure(peripheralID: String) {
failureCounts[peripheralID, default: 0] += 1
}
func recordDisconnectError(peripheralID: String, at now: Date) {
recentConnectTimeouts[peripheralID] = now
}
func recordConnectionTimeout(peripheralID: String, at now: Date) {
recentConnectTimeouts[peripheralID] = now
recordConnectionFailure(peripheralID: peripheralID)
}
func pruneConnectionTimeouts(before cutoff: Date) {
recentConnectTimeouts = recentConnectTimeouts.filter { $0.value >= cutoff }
}
func reset() {
lastGlobalConnectAttempt = .distantPast
candidates.removeAll()
failureCounts.removeAll()
recentConnectTimeouts.removeAll()
lastIsolatedAt = nil
dynamicRSSIThreshold = initialDynamicRSSIThreshold
}
@discardableResult
func updateRSSIThreshold(
connectedCount: Int,
connectedOrConnectingLinkCount: Int,
now: Date
) -> Int {
if connectedCount == 0 {
if lastIsolatedAt == nil { lastIsolatedAt = now }
let isolatedAt = lastIsolatedAt ?? now
let elapsed = now.timeIntervalSince(isolatedAt)
dynamicRSSIThreshold = elapsed > TransportConfig.bleIsolationRelaxThresholdSeconds
? TransportConfig.bleRSSIIsolatedRelaxed
: TransportConfig.bleRSSIIsolatedBase
return dynamicRSSIThreshold
}
lastIsolatedAt = nil
var threshold = TransportConfig.bleDynamicRSSIThresholdDefault
if connectedOrConnectingLinkCount >= maxCentralLinks || candidates.count >= candidateCap {
threshold = TransportConfig.bleRSSIConnectedThreshold
}
let recentTimeouts = recentConnectTimeouts.filter {
now.timeIntervalSince($0.value) < recentTimeoutWindowSeconds
}.count
if recentTimeouts >= recentTimeoutCountThreshold {
threshold = max(threshold, TransportConfig.bleRSSIHighTimeoutThreshold)
}
dynamicRSSIThreshold = threshold
return threshold
}
private func rateLimitRetryDelay(now: Date) -> TimeInterval? {
let elapsed = now.timeIntervalSince(lastGlobalConnectAttempt)
guard elapsed < connectRateLimitInterval else { return nil }
return connectRateLimitInterval - elapsed + 0.05
}
private func weakLinkRetryDelay(
for candidate: BLEConnectionCandidate<Peripheral>,
now: Date
) -> TimeInterval? {
guard let lastTimeout = recentConnectTimeouts[candidate.peripheralID] else { return nil }
let elapsed = now.timeIntervalSince(lastTimeout)
guard elapsed < weakLinkCooldownSeconds && candidate.rssi <= weakLinkRSSICutoff else { return nil }
let remaining = weakLinkCooldownSeconds - elapsed
return min(max(2.0, remaining), 15.0)
}
private func score(_ candidate: BLEConnectionCandidate<Peripheral>, now: Date) -> Int {
let failures = failureCounts[candidate.peripheralID] ?? 0
let penalty = min(20, 1 << min(4, failures))
let timeoutBias = recentConnectTimeouts[candidate.peripheralID].map {
now.timeIntervalSince($0) < 60 ? 10 : 0
} ?? 0
let base = (candidate.isConnectable ? 1000 : 0) + (candidate.rssi + 100) * 2
let recency = -Int(now.timeIntervalSince(candidate.discoveredAt) * 10)
return base + recency - penalty - timeoutBias
}
}
@@ -0,0 +1,71 @@
import BitFoundation
import Foundation
struct BLEDirectedRelaySpoolEntry {
let recipient: PeerID
let packet: BitchatPacket
}
struct BLEDirectedRelaySpool {
private struct StoredPacket {
let packet: BitchatPacket
let enqueuedAt: Date
}
private var packetsByRecipient: [PeerID: [String: StoredPacket]] = [:]
var isEmpty: Bool {
packetsByRecipient.isEmpty
}
var count: Int {
packetsByRecipient.values.reduce(0) { $0 + $1.count }
}
@discardableResult
mutating func enqueue(
packet: BitchatPacket,
recipient: PeerID,
messageID: String,
enqueuedAt: Date
) -> Bool {
var packets = packetsByRecipient[recipient] ?? [:]
guard packets[messageID] == nil else {
return false
}
packets[messageID] = StoredPacket(packet: packet, enqueuedAt: enqueuedAt)
packetsByRecipient[recipient] = packets
return true
}
mutating func drainUnexpired(now: Date, window: TimeInterval) -> [BLEDirectedRelaySpoolEntry] {
var entries: [BLEDirectedRelaySpoolEntry] = []
for (recipient, packets) in packetsByRecipient {
for stored in packets.values where now.timeIntervalSince(stored.enqueuedAt) <= window {
entries.append(BLEDirectedRelaySpoolEntry(recipient: recipient, packet: stored.packet))
}
}
packetsByRecipient.removeAll()
return entries
}
mutating func pruneExpired(now: Date, window: TimeInterval) {
guard !packetsByRecipient.isEmpty else { return }
var pruned: [PeerID: [String: StoredPacket]] = [:]
for (recipient, packets) in packetsByRecipient {
let freshPackets = packets.filter { now.timeIntervalSince($0.value.enqueuedAt) <= window }
if !freshPackets.isEmpty {
pruned[recipient] = freshPackets
}
}
packetsByRecipient = pruned
}
mutating func removeAll() {
packetsByRecipient.removeAll()
}
}
@@ -0,0 +1,109 @@
import BitFoundation
import CryptoKit
import Foundation
struct BLEFanoutSelection: Equatable {
let peripheralIDs: Set<String>
let centralIDs: Set<String>
}
enum BLEFanoutSelector {
static func selectLinks(
peripheralIDs: [String],
centralIDs: [String],
ingressLink: BLEIngressLinkID?,
excludedLinks: Set<BLEIngressLinkID> = [],
directedPeerHint: PeerID?,
packetType: UInt8,
messageID: String
) -> BLEFanoutSelection {
let allowed = allowedLinks(
peripheralIDs: peripheralIDs,
centralIDs: centralIDs,
ingressLink: ingressLink,
excludedLinks: excludedLinks
)
guard shouldSubset(packetType: packetType, directedPeerHint: directedPeerHint) else {
return BLEFanoutSelection(
peripheralIDs: Set(allowed.peripheralIDs),
centralIDs: Set(allowed.centralIDs)
)
}
return BLEFanoutSelection(
peripheralIDs: deterministicSubset(
ids: allowed.peripheralIDs,
k: subsetSize(for: allowed.peripheralIDs.count),
seed: messageID
),
centralIDs: deterministicSubset(
ids: allowed.centralIDs,
k: subsetSize(for: allowed.centralIDs.count),
seed: messageID
)
)
}
private static func allowedLinks(
peripheralIDs: [String],
centralIDs: [String],
ingressLink: BLEIngressLinkID?,
excludedLinks: Set<BLEIngressLinkID>
) -> (peripheralIDs: [String], centralIDs: [String]) {
var allowedPeripheralIDs = peripheralIDs
var allowedCentralIDs = centralIDs
var blockedLinks = excludedLinks
if let ingressLink {
blockedLinks.insert(ingressLink)
}
allowedPeripheralIDs.removeAll { blockedLinks.contains(.peripheral($0)) }
allowedCentralIDs.removeAll { blockedLinks.contains(.central($0)) }
return (allowedPeripheralIDs, allowedCentralIDs)
}
private static func shouldSubset(packetType: UInt8, directedPeerHint: PeerID?) -> Bool {
directedPeerHint == nil
&& packetType != MessageType.fragment.rawValue
&& packetType != MessageType.announce.rawValue
&& packetType != MessageType.requestSync.rawValue
}
private static func subsetSize(for count: Int) -> Int {
guard count > 0 else { return 0 }
if count <= 2 { return count }
var value = count - 1
var bits = 0
while value > 0 {
value >>= 1
bits += 1
}
return min(count, max(1, bits + 1))
}
private static func deterministicSubset(ids: [String], k: Int, seed: String) -> Set<String> {
guard k > 0 && ids.count > k else { return Set(ids) }
var scored: [(score: [UInt8], id: String)] = []
for id in ids {
let data = (seed + "::" + id).data(using: .utf8) ?? Data()
let digest = Array(SHA256.hash(data: data))
scored.append((digest, id))
}
scored.sort { lhs, rhs in
for index in 0..<min(lhs.score.count, rhs.score.count) {
if lhs.score[index] != rhs.score[index] {
return lhs.score[index] < rhs.score[index]
}
}
return lhs.id < rhs.id
}
return Set(scored.prefix(k).map(\.id))
}
}
@@ -0,0 +1,71 @@
import BitFoundation
import Foundation
struct BLEFileTransferDeliveryPlan: Equatable {
let isPrivateMessage: Bool
let shouldTrackForSync: Bool
}
enum BLEFileTransferPolicy {
static func isSelfEcho(packet: BitchatPacket, from peerID: PeerID, localPeerID: PeerID) -> Bool {
peerID == localPeerID && packet.ttl != 0
}
static func deliveryPlan(packet: BitchatPacket, localPeerID: PeerID) -> BLEFileTransferDeliveryPlan? {
guard let recipientID = packet.recipientID else {
return BLEFileTransferDeliveryPlan(isPrivateMessage: false, shouldTrackForSync: true)
}
let isBroadcast = recipientID.allSatisfy { $0 == 0xFF }
if isBroadcast {
return BLEFileTransferDeliveryPlan(isPrivateMessage: false, shouldTrackForSync: true)
}
guard PeerID(hexData: recipientID) == localPeerID else {
return nil
}
return BLEFileTransferDeliveryPlan(isPrivateMessage: true, shouldTrackForSync: false)
}
}
struct BLEIncomingFileAcceptance {
let filePacket: BitchatFilePacket
let mime: MimeType
}
enum BLEIncomingFileRejection: Error, Equatable {
case malformedPayload
case payloadTooLarge(bytes: Int)
case unsupportedMime(mimeType: String?, bytes: Int)
case magicMismatch(mime: MimeType, bytes: Int, prefixHex: String)
}
enum BLEIncomingFileValidator {
static func validate(payload: Data) -> Result<BLEIncomingFileAcceptance, BLEIncomingFileRejection> {
guard let filePacket = BitchatFilePacket.decode(payload) else {
return .failure(.malformedPayload)
}
guard FileTransferLimits.isValidPayload(filePacket.content.count) else {
return .failure(.payloadTooLarge(bytes: filePacket.content.count))
}
guard let mime = MimeType(filePacket.mimeType), mime.isAllowed else {
return .failure(.unsupportedMime(
mimeType: filePacket.mimeType,
bytes: filePacket.content.count
))
}
guard mime.matches(data: filePacket.content) else {
return .failure(.magicMismatch(
mime: mime,
bytes: filePacket.content.count,
prefixHex: filePacket.content.prefix(20).map { String(format: "%02x", $0) }.joined(separator: " ")
))
}
return .success(BLEIncomingFileAcceptance(filePacket: filePacket, mime: mime))
}
}
@@ -0,0 +1,153 @@
import BitFoundation
import Foundation
struct BLEFragmentKey: Hashable, Equatable {
let sender: UInt64
let id: UInt64
}
struct BLEFragmentHeader: Equatable {
let key: BLEFragmentKey
let index: Int
let total: Int
let originalType: UInt8
let fragmentData: Data
let isBroadcastFragment: Bool
var idLogString: String {
String(format: "%016llx", key.id)
}
init?(packet: BitchatPacket) {
// Minimum header: 8 bytes ID + 2 index + 2 total + 1 type.
guard packet.payload.count >= 13 else { return nil }
var senderU64: UInt64 = 0
for byte in packet.senderID.prefix(8) {
senderU64 = (senderU64 << 8) | UInt64(byte)
}
var fragmentU64: UInt64 = 0
for byte in packet.payload.prefix(8) {
fragmentU64 = (fragmentU64 << 8) | UInt64(byte)
}
let index = Int((UInt16(packet.payload[8]) << 8) | UInt16(packet.payload[9]))
let total = Int((UInt16(packet.payload[10]) << 8) | UInt16(packet.payload[11]))
guard total > 0 && total <= 10_000 && index >= 0 && index < total else {
return nil
}
let isBroadcastFragment: Bool = {
guard let recipient = packet.recipientID else { return true }
return recipient.count == 8 && recipient.allSatisfy { $0 == 0xFF }
}()
self.key = BLEFragmentKey(sender: senderU64, id: fragmentU64)
self.index = index
self.total = total
self.originalType = packet.payload[12]
self.fragmentData = Data(packet.payload.suffix(from: 13))
self.isBroadcastFragment = isBroadcastFragment
}
}
struct BLEFragmentAssemblyBuffer {
enum AppendResult: Equatable {
case stored(header: BLEFragmentHeader, started: Bool)
case complete(header: BLEFragmentHeader, reassembledData: Data, started: Bool)
case oversized(header: BLEFragmentHeader, projectedSize: Int, limit: Int, started: Bool)
}
private struct Metadata {
let type: UInt8
let total: Int
let timestamp: Date
}
private var fragmentsByKey: [BLEFragmentKey: [Int: Data]] = [:]
private var metadataByKey: [BLEFragmentKey: Metadata] = [:]
mutating func removeAll() {
fragmentsByKey.removeAll()
metadataByKey.removeAll()
}
@discardableResult
mutating func removeExpired(before cutoff: Date) -> Int {
let expiredKeys = metadataByKey
.filter { $0.value.timestamp < cutoff }
.map(\.key)
for key in expiredKeys {
fragmentsByKey.removeValue(forKey: key)
metadataByKey.removeValue(forKey: key)
}
return expiredKeys.count
}
mutating func append(
_ header: BLEFragmentHeader,
maxInFlightAssemblies: Int,
now: Date = Date()
) -> AppendResult {
let started = startAssemblyIfNeeded(for: header, maxInFlightAssemblies: maxInFlightAssemblies, now: now)
let currentSize = fragmentsByKey[header.key]?.values.reduce(0) { $0 + $1.count } ?? 0
let limit = Self.assemblyLimit(for: header.originalType)
let projectedSize = currentSize + header.fragmentData.count
guard projectedSize <= limit else {
fragmentsByKey.removeValue(forKey: header.key)
metadataByKey.removeValue(forKey: header.key)
return .oversized(header: header, projectedSize: projectedSize, limit: limit, started: started)
}
fragmentsByKey[header.key]?[header.index] = header.fragmentData
guard let fragments = fragmentsByKey[header.key],
fragments.count == header.total else {
return .stored(header: header, started: started)
}
let reassembled = (0..<header.total).reduce(into: Data()) { data, index in
if let fragment = fragments[index] {
data.append(fragment)
}
}
fragmentsByKey.removeValue(forKey: header.key)
metadataByKey.removeValue(forKey: header.key)
return .complete(header: header, reassembledData: reassembled, started: started)
}
private mutating func startAssemblyIfNeeded(
for header: BLEFragmentHeader,
maxInFlightAssemblies: Int,
now: Date
) -> Bool {
guard fragmentsByKey[header.key] == nil else { return false }
if fragmentsByKey.count >= maxInFlightAssemblies,
let oldest = metadataByKey.min(by: { $0.value.timestamp < $1.value.timestamp })?.key {
fragmentsByKey.removeValue(forKey: oldest)
metadataByKey.removeValue(forKey: oldest)
}
fragmentsByKey[header.key] = [:]
metadataByKey[header.key] = Metadata(type: header.originalType, total: header.total, timestamp: now)
return true
}
private static func assemblyLimit(for originalType: UInt8) -> Int {
if originalType == MessageType.fileTransfer.rawValue {
// Allow headroom for TLV metadata and binary framing overhead.
return FileTransferLimits.maxFramedFileBytes
}
return FileTransferLimits.maxPayloadBytes
}
}
@@ -0,0 +1,70 @@
import BitFoundation
import Foundation
struct BLEInboundWriteChunk: Equatable {
let offset: Int
let data: Data
}
struct BLEInboundWriteAppendMetadata: Equatable {
let accumulatedBytes: Int
let appendedBytes: Int
let offsets: [Int]
let packetType: UInt8?
}
struct BLEInboundWriteBuffer {
enum AppendResult {
case decoded(packet: BitchatPacket, metadata: BLEInboundWriteAppendMetadata)
case waiting(metadata: BLEInboundWriteAppendMetadata)
case oversized(metadata: BLEInboundWriteAppendMetadata)
}
private var buffersByCentralID: [String: Data] = [:]
mutating func removeAll() {
buffersByCentralID.removeAll()
}
mutating func append(
chunks: [BLEInboundWriteChunk],
for centralID: String,
capBytes: Int
) -> AppendResult {
var combined = buffersByCentralID[centralID] ?? Data()
var appendedBytes = 0
var offsets: [Int] = []
for chunk in chunks where !chunk.data.isEmpty {
offsets.append(chunk.offset)
let end = chunk.offset + chunk.data.count
if combined.count < end {
combined.append(Data(repeating: 0, count: end - combined.count))
}
combined.replaceSubrange(chunk.offset..<end, with: chunk.data)
appendedBytes += chunk.data.count
}
let metadata = BLEInboundWriteAppendMetadata(
accumulatedBytes: combined.count,
appendedBytes: appendedBytes,
offsets: offsets,
packetType: combined.count >= 2 ? combined[1] : nil
)
if let packet = BinaryProtocol.decode(combined) {
buffersByCentralID.removeValue(forKey: centralID)
return .decoded(packet: packet, metadata: metadata)
}
guard combined.count <= capBytes else {
buffersByCentralID.removeValue(forKey: centralID)
return .oversized(metadata: metadata)
}
buffersByCentralID[centralID] = combined
return .waiting(metadata: metadata)
}
}
@@ -0,0 +1,168 @@
import BitLogger
import BitFoundation
import Foundation
struct BLEIncomingFileStore {
private static let quotaBytes: Int64 = 100 * 1024 * 1024
private let fileManager: FileManager
private let baseDirectory: URL?
private let dateProvider: () -> Date
init(fileManager: FileManager = .default, baseDirectory: URL? = nil, dateProvider: @escaping () -> Date = Date.init) {
self.fileManager = fileManager
self.baseDirectory = baseDirectory
self.dateProvider = dateProvider
}
func save(
data: Data,
preferredName: String?,
subdirectory: String,
fallbackExtension: String?,
defaultPrefix: String
) -> URL? {
do {
let base = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true)
try fileManager.createDirectory(at: base, withIntermediateDirectories: true, attributes: nil)
let sanitized = sanitizedFileName(
preferredName,
defaultName: "\(defaultPrefix)_\(Self.timestampString(from: dateProvider()))",
fallbackExtension: fallbackExtension
)
let destination = uniqueFileURL(in: base, fileName: sanitized)
try data.write(to: destination, options: .atomic)
return destination
} catch {
SecureLogger.error("❌ Failed to persist incoming media: \(error)", category: .session)
return nil
}
}
func enforceQuota(reservingBytes: Int) {
do {
let base = try filesDirectory()
let incomingDirs = [
base.appendingPathComponent("voicenotes/incoming", isDirectory: true),
base.appendingPathComponent("images/incoming", isDirectory: true),
base.appendingPathComponent("files/incoming", isDirectory: true)
]
var allFiles: [(url: URL, size: Int64, modified: Date)] = []
for dir in incomingDirs where fileManager.fileExists(atPath: dir.path) {
guard let contents = try? fileManager.contentsOfDirectory(
at: dir,
includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey],
options: [.skipsHiddenFiles]
) else { continue }
for fileURL in contents {
guard let attrs = try? fileURL.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]),
let size = attrs.fileSize,
let modified = attrs.contentModificationDate else { continue }
allFiles.append((url: fileURL, size: Int64(size), modified: modified))
}
}
let currentUsage = allFiles.reduce(0) { $0 + $1.size }
let targetUsage = Self.quotaBytes - Int64(reservingBytes)
guard currentUsage > targetUsage else { return }
let needToFree = currentUsage - targetUsage
var freedSpace: Int64 = 0
for file in allFiles.sorted(by: { $0.modified < $1.modified }) {
guard freedSpace < needToFree else { break }
do {
try fileManager.removeItem(at: file.url)
freedSpace += file.size
SecureLogger.debug("🗑️ BCH-01-002: Deleted old incoming file to free space: \(file.url.lastPathComponent)", category: .security)
} catch {
SecureLogger.warning("⚠️ Failed to delete old file for quota: \(error)", category: .security)
}
}
if freedSpace > 0 {
SecureLogger.info("📊 BCH-01-002: Freed \(ByteCountFormatter.string(fromByteCount: freedSpace, countStyle: .file)) to stay within incoming files quota", category: .security)
}
} catch {
SecureLogger.warning("⚠️ Could not enforce storage quota: \(error)", category: .security)
}
}
private func filesDirectory() throws -> URL {
let root = try baseDirectory ?? fileManager.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let filesDir = root.appendingPathComponent("files", isDirectory: true)
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
return filesDir
}
private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
var candidate = (name ?? "")
.replacingOccurrences(of: "\0", with: "")
.precomposedStringWithCanonicalMapping
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "\\", with: "_")
let invalid = CharacterSet(charactersIn: "<>:\"|?*\0").union(.controlCharacters)
candidate = candidate.components(separatedBy: invalid).joined(separator: "_").trimmed
if candidate.isEmpty { candidate = defaultName }
if candidate.hasPrefix(".") { candidate = "_" + candidate }
if candidate.count > 120 {
let ext = (candidate as NSString).pathExtension
let base = (candidate as NSString).deletingPathExtension
candidate = ext.isEmpty
? String(candidate.prefix(120))
: String(base.prefix(max(10, 120 - ext.count - 1))) + "." + ext
}
if let fallbackExtension, (candidate as NSString).pathExtension.isEmpty {
candidate += ".\(fallbackExtension)"
}
return candidate.isEmpty ? defaultName : candidate
}
private func uniqueFileURL(in directory: URL, fileName: String) -> URL {
let directoryPath = directory.standardizedFileURL.path
func isInsideDirectory(_ url: URL) -> Bool {
url.standardizedFileURL.path.hasPrefix(directoryPath + "/")
}
var candidate = directory.appendingPathComponent(fileName)
guard isInsideDirectory(candidate) else {
SecureLogger.warning("⚠️ Path traversal blocked: \(fileName)", category: .security)
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
}
if !fileManager.fileExists(atPath: candidate.path) {
return candidate
}
let baseName = (fileName as NSString).deletingPathExtension
let ext = (fileName as NSString).pathExtension
for counter in 1..<100 {
let newName = ext.isEmpty ? "\(baseName) (\(counter))" : "\(baseName) (\(counter)).\(ext)"
candidate = directory.appendingPathComponent(newName)
guard isInsideDirectory(candidate) else {
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
}
if !fileManager.fileExists(atPath: candidate.path) {
return candidate
}
}
return directory.appendingPathComponent("\(baseName)_\(UUID().uuidString).\(ext.isEmpty ? "dat" : ext)")
}
private static func timestampString(from date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd_HHmmss"
return formatter.string(from: date)
}
}
@@ -0,0 +1,108 @@
import BitFoundation
import Foundation
enum BLEIngressLinkID: Hashable, Equatable {
case peripheral(String)
case central(String)
}
struct BLEIngressPacketContext: Equatable {
let receivedFromPeerID: PeerID
let validationPeerID: PeerID
}
struct BLEIngressLinkRecord: Equatable {
let link: BLEIngressLinkID
let peerID: PeerID
let timestamp: Date
}
enum BLEIngressRejection: Error, Equatable {
case selfLoopback(packetType: UInt8)
case directSenderMismatch(boundPeerID: PeerID, claimedSenderID: PeerID)
}
struct BLEIngressLinkRegistry {
private var ingressByMessageID: [String: BLEIngressLinkRecord] = [:]
var isEmpty: Bool {
ingressByMessageID.isEmpty
}
mutating func removeAll() {
ingressByMessageID.removeAll()
}
func record(for packet: BitchatPacket) -> BLEIngressLinkRecord? {
ingressByMessageID[Self.messageID(for: packet)]
}
func link(for packet: BitchatPacket) -> BLEIngressLinkID? {
record(for: packet)?.link
}
func peerID(for packet: BitchatPacket) -> PeerID? {
record(for: packet)?.peerID
}
mutating func recordIfNew(
_ packet: BitchatPacket,
link: BLEIngressLinkID,
peerID: PeerID,
now: Date = Date(),
lifetime: TimeInterval
) -> Bool {
let messageID = Self.messageID(for: packet)
if let existing = ingressByMessageID[messageID],
now.timeIntervalSince(existing.timestamp) <= lifetime {
return false
}
ingressByMessageID[messageID] = BLEIngressLinkRecord(link: link, peerID: peerID, timestamp: now)
return true
}
mutating func prune(before cutoff: Date) {
ingressByMessageID = ingressByMessageID.filter { $0.value.timestamp >= cutoff }
}
static func packetContext(
for packet: BitchatPacket,
claimedSenderID: PeerID,
boundPeerID: PeerID?,
localPeerID: PeerID,
directAnnounceTTL: UInt8
) -> Result<BLEIngressPacketContext, BLEIngressRejection> {
if claimedSenderID == localPeerID,
!isSelfAuthoredSyncResponse(packet) {
return .failure(.selfLoopback(packetType: packet.type))
}
if let boundPeerID,
boundPeerID != claimedSenderID,
requiresDirectSenderBinding(packet, directAnnounceTTL: directAnnounceTTL) {
return .failure(.directSenderMismatch(boundPeerID: boundPeerID, claimedSenderID: claimedSenderID))
}
let receivedFromPeerID = boundPeerID ?? claimedSenderID
let validationPeerID = packet.isRSR ? receivedFromPeerID : claimedSenderID
return .success(BLEIngressPacketContext(
receivedFromPeerID: receivedFromPeerID,
validationPeerID: validationPeerID
))
}
static func messageID(for packet: BitchatPacket) -> String {
let senderID = packet.senderID.hexEncodedString()
let digestPrefix = packet.payload.sha256Hash().prefix(4).hexEncodedString()
return "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)"
}
private static func requiresDirectSenderBinding(_ packet: BitchatPacket, directAnnounceTTL: UInt8) -> Bool {
packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL
}
private static func isSelfAuthoredSyncResponse(_ packet: BitchatPacket) -> Bool {
packet.isRSR && packet.ttl == 0
}
}
@@ -0,0 +1,76 @@
import BitFoundation
import Foundation
enum BLEIngressPacketGuard {
enum Rejection: Error, Equatable {
case selfLoopback(packetType: UInt8)
case directSenderMismatch(boundPeerID: PeerID, claimedSenderID: PeerID)
case invalidRSR(peerID: PeerID)
case timestampSkew(peerID: PeerID, skewMs: UInt64, maxSkewMs: UInt64)
}
static func evaluate(
packet: BitchatPacket,
claimedSenderID: PeerID,
boundPeerID: PeerID?,
localPeerID: PeerID,
directAnnounceTTL: UInt8,
nowMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000),
maxTimestampSkewMs: UInt64 = 120_000,
isValidSyncResponse: (PeerID) -> Bool
) -> Result<BLEIngressPacketContext, Rejection> {
let contextResult = BLEIngressLinkRegistry.packetContext(
for: packet,
claimedSenderID: claimedSenderID,
boundPeerID: boundPeerID,
localPeerID: localPeerID,
directAnnounceTTL: directAnnounceTTL
)
let context: BLEIngressPacketContext
switch contextResult {
case .success(let acceptedContext):
context = acceptedContext
case .failure(.selfLoopback(let packetType)):
return .failure(.selfLoopback(packetType: packetType))
case .failure(.directSenderMismatch(let boundPeerID, let claimedSenderID)):
return .failure(.directSenderMismatch(boundPeerID: boundPeerID, claimedSenderID: claimedSenderID))
}
switch validatePayload(
packet,
from: context.validationPeerID,
nowMs: nowMs,
maxTimestampSkewMs: maxTimestampSkewMs,
isValidSyncResponse: isValidSyncResponse
) {
case .success:
return .success(context)
case .failure(let rejection):
return .failure(rejection)
}
}
static func validatePayload(
_ packet: BitchatPacket,
from peerID: PeerID,
nowMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000),
maxTimestampSkewMs: UInt64 = 120_000,
isValidSyncResponse: (PeerID) -> Bool
) -> Result<Void, Rejection> {
if packet.isRSR {
guard isValidSyncResponse(peerID) else {
return .failure(.invalidRSR(peerID: peerID))
}
return .success(())
}
let packetTime = packet.timestamp
let skew = packetTime > nowMs ? packetTime - nowMs : nowMs - packetTime
guard skew <= maxTimestampSkewMs else {
return .failure(.timestampSkew(peerID: peerID, skewMs: skew, maxSkewMs: maxTimestampSkewMs))
}
return .success(())
}
}
@@ -0,0 +1,196 @@
import BitFoundation
import CoreBluetooth
import Foundation
struct BLEPeripheralLinkState {
let peripheral: CBPeripheral
var characteristic: CBCharacteristic?
var peerID: PeerID?
var isConnecting: Bool
var isConnected: Bool
var lastConnectionAttempt: Date?
var assembler: NotificationStreamAssembler
}
struct BLEDirectLinkState: Equatable {
let hasPeripheral: Bool
let hasCentral: Bool
}
struct BLESubscribedCentralSnapshot {
let centrals: [CBCentral]
let peerIDsByCentralUUID: [String: PeerID]
func central(for peerID: PeerID) -> CBCentral? {
centrals.first { peerIDsByCentralUUID[$0.identifier.uuidString] == peerID }
}
}
final class BLELinkStateStore {
private(set) var peripherals: [String: BLEPeripheralLinkState] = [:]
private(set) var peerToPeripheralUUID: [PeerID: String] = [:]
private(set) var subscribedCentrals: [CBCentral] = []
private(set) var centralToPeerID: [String: PeerID] = [:]
var peripheralStates: [BLEPeripheralLinkState] {
Array(peripherals.values)
}
var subscribedCentralSnapshot: BLESubscribedCentralSnapshot {
BLESubscribedCentralSnapshot(
centrals: subscribedCentrals,
peerIDsByCentralUUID: centralToPeerID
)
}
var subscribedCentralCount: Int {
subscribedCentrals.count
}
var connectedOrConnectingPeripheralCount: Int {
peripherals.values.filter { $0.isConnected || $0.isConnecting }.count
}
func state(forPeripheralID peripheralID: String) -> BLEPeripheralLinkState? {
peripherals[peripheralID]
}
func setPeripheralState(_ state: BLEPeripheralLinkState, for peripheralID: String) {
peripherals[peripheralID] = state
}
@discardableResult
func updatePeripheral(
_ peripheralID: String,
_ update: (inout BLEPeripheralLinkState) -> Void
) -> BLEPeripheralLinkState? {
guard var state = peripherals[peripheralID] else { return nil }
update(&state)
peripherals[peripheralID] = state
return state
}
func beginConnecting(to peripheral: CBPeripheral, at date: Date) {
setPeripheralState(
BLEPeripheralLinkState(
peripheral: peripheral,
characteristic: nil,
peerID: nil,
isConnecting: true,
isConnected: false,
lastConnectionAttempt: date,
assembler: NotificationStreamAssembler()
),
for: peripheral.identifier.uuidString
)
}
func markConnected(_ peripheral: CBPeripheral) {
let peripheralID = peripheral.identifier.uuidString
if updatePeripheral(peripheralID, {
$0.isConnecting = false
$0.isConnected = true
}) == nil {
setPeripheralState(
BLEPeripheralLinkState(
peripheral: peripheral,
characteristic: nil,
peerID: nil,
isConnecting: false,
isConnected: true,
lastConnectionAttempt: nil,
assembler: NotificationStreamAssembler()
),
for: peripheralID
)
}
}
func updateCharacteristic(_ characteristic: CBCharacteristic, forPeripheralID peripheralID: String) {
updatePeripheral(peripheralID) {
$0.characteristic = characteristic
}
}
func directPeripheralState(for peerID: PeerID) -> BLEPeripheralLinkState? {
peerToPeripheralUUID[peerID].flatMap { peripherals[$0] }
}
func directLinkState(for peerID: PeerID) -> BLEDirectLinkState {
let peripheralUUID = peerToPeripheralUUID[peerID]
let hasPeripheral = peripheralUUID.flatMap { peripherals[$0]?.isConnected } ?? false
let hasCentral = centralToPeerID.values.contains(peerID)
return BLEDirectLinkState(hasPeripheral: hasPeripheral, hasCentral: hasCentral)
}
func links(to peerID: PeerID?) -> Set<BLEIngressLinkID> {
guard let peerID else { return [] }
var links: Set<BLEIngressLinkID> = []
if let peripheralUUID = peerToPeripheralUUID[peerID] {
links.insert(.peripheral(peripheralUUID))
}
for (centralUUID, mappedPeerID) in centralToPeerID where mappedPeerID == peerID {
links.insert(.central(centralUUID))
}
return links
}
func peerID(forPeripheralID peripheralID: String) -> PeerID? {
peripherals[peripheralID]?.peerID
}
func peerID(forCentralUUID centralUUID: String) -> PeerID? {
centralToPeerID[centralUUID]
}
func addSubscribedCentral(_ central: CBCentral) {
guard !subscribedCentrals.contains(central) else { return }
subscribedCentrals.append(central)
}
func removeSubscribedCentral(_ central: CBCentral) -> PeerID? {
let centralUUID = central.identifier.uuidString
subscribedCentrals.removeAll { $0.identifier == central.identifier }
return centralToPeerID.removeValue(forKey: centralUUID)
}
func bindCentral(_ centralUUID: String, to peerID: PeerID) {
centralToPeerID[centralUUID] = peerID
}
func bindPeripheral(_ peripheralUUID: String, to peerID: PeerID) {
if updatePeripheral(peripheralUUID, { $0.peerID = peerID }) != nil {
peerToPeripheralUUID[peerID] = peripheralUUID
}
}
func removePeripheral(_ peripheralID: String) -> PeerID? {
let peerID = peripherals.removeValue(forKey: peripheralID)?.peerID
if let peerID {
peerToPeripheralUUID.removeValue(forKey: peerID)
}
return peerID
}
func clearPeripherals() -> [PeerID] {
let peerIDs = peripherals.compactMap { $0.value.peerID }
peripherals.removeAll()
peerToPeripheralUUID.removeAll()
return peerIDs
}
func clearCentrals() -> [PeerID] {
let peerIDs = Array(centralToPeerID.values)
subscribedCentrals.removeAll()
centralToPeerID.removeAll()
return peerIDs
}
func clearAll() {
peripherals.removeAll()
peerToPeripheralUUID.removeAll()
subscribedCentrals.removeAll()
centralToPeerID.removeAll()
}
}
@@ -0,0 +1,33 @@
import Foundation
final class BLELogRateLimiter {
private let defaultMinimumInterval: TimeInterval
private let queue = DispatchQueue(label: "chat.bitchat.ble.log-rate-limiter")
private var lastLogTimeByKey: [String: Date] = [:]
init(defaultMinimumInterval: TimeInterval) {
self.defaultMinimumInterval = defaultMinimumInterval
}
func shouldLog(
key: String,
now: Date = Date(),
minimumInterval: TimeInterval? = nil
) -> Bool {
queue.sync {
let interval = minimumInterval ?? defaultMinimumInterval
if let lastLogTime = lastLogTimeByKey[key],
now.timeIntervalSince(lastLogTime) < interval {
return false
}
lastLogTimeByKey[key] = now
return true
}
}
func removeAll() {
queue.sync {
lastLogTimeByKey.removeAll()
}
}
}
@@ -0,0 +1,62 @@
import Foundation
struct BLEMaintenancePlan: Equatable {
let shouldSendAnnounce: Bool
let shouldEnsureAdvertising: Bool
let shouldRunCleanup: Bool
let shouldFlushDirectedSpool: Bool
let shouldResetCounter: Bool
}
enum BLEMaintenancePolicy {
static func plan(
cycle: Int,
connectedCount: Int,
peerRegistryIsEmpty: Bool,
elapsedSinceLastAnnounce: TimeInterval,
hasRecentTraffic: Bool,
connectedAnnounceJitterOffset: TimeInterval? = nil,
highDegreeThreshold: Int = TransportConfig.bleHighDegreeThreshold
) -> BLEMaintenancePlan {
BLEMaintenancePlan(
shouldSendAnnounce: shouldSendAnnounce(
connectedCount: connectedCount,
elapsedSinceLastAnnounce: elapsedSinceLastAnnounce,
hasRecentTraffic: hasRecentTraffic,
connectedAnnounceJitterOffset: connectedAnnounceJitterOffset,
highDegreeThreshold: highDegreeThreshold
),
shouldEnsureAdvertising: peerRegistryIsEmpty,
shouldRunCleanup: cycle.isMultiple(of: 3),
shouldFlushDirectedSpool: !cycle.isMultiple(of: 2),
shouldResetCounter: cycle >= 6
)
}
static func shouldSendAnnounce(
connectedCount: Int,
elapsedSinceLastAnnounce: TimeInterval,
hasRecentTraffic: Bool,
connectedAnnounceJitterOffset: TimeInterval? = nil,
highDegreeThreshold: Int = TransportConfig.bleHighDegreeThreshold
) -> Bool {
if hasRecentTraffic && elapsedSinceLastAnnounce >= 10.0 {
return true
}
guard connectedCount > 0 else {
return elapsedSinceLastAnnounce >= TransportConfig.bleAnnounceIntervalSeconds
}
let highDegree = connectedCount >= highDegreeThreshold
let base = highDegree ?
TransportConfig.bleConnectedAnnounceBaseSecondsDense :
TransportConfig.bleConnectedAnnounceBaseSecondsSparse
let jitter = highDegree ?
TransportConfig.bleConnectedAnnounceJitterDense :
TransportConfig.bleConnectedAnnounceJitterSparse
let jitterOffset = connectedAnnounceJitterOffset ?? Double.random(in: -jitter...jitter)
return elapsedSinceLastAnnounce >= base + jitterOffset
}
}
@@ -0,0 +1,25 @@
import Foundation
enum BLENoisePayloadFactory {
static func privateMessage(content: String, messageID: String) -> Data? {
guard let payload = PrivateMessagePacket(messageID: messageID, content: content).encode() else {
return nil
}
return typedPayload(.privateMessage, payload: payload)
}
static func readReceipt(originalMessageID: String) -> Data {
typedPayload(.readReceipt, payload: Data(originalMessageID.utf8))
}
static func delivered(messageID: String) -> Data {
typedPayload(.delivered, payload: Data(messageID.utf8))
}
static func typedPayload(_ type: NoisePayloadType, payload: Data) -> Data {
var typed = Data([type.rawValue])
typed.append(payload)
return typed
}
}
@@ -0,0 +1,46 @@
import BitFoundation
import Foundation
struct BLEPendingPrivateMessage: Equatable {
let content: String
let messageID: String
}
struct BLENoiseSessionQueues {
private var privateMessagesByPeerID: [PeerID: [BLEPendingPrivateMessage]] = [:]
private var typedPayloadsByPeerID: [PeerID: [Data]] = [:]
var isEmpty: Bool {
privateMessagesByPeerID.isEmpty && typedPayloadsByPeerID.isEmpty
}
mutating func removeAll() {
privateMessagesByPeerID.removeAll()
typedPayloadsByPeerID.removeAll()
}
mutating func appendPrivateMessage(content: String, messageID: String, for peerID: PeerID) {
privateMessagesByPeerID[peerID, default: []].append(BLEPendingPrivateMessage(content: content, messageID: messageID))
}
mutating func takePrivateMessages(for peerID: PeerID) -> [BLEPendingPrivateMessage] {
let messages = privateMessagesByPeerID[peerID] ?? []
privateMessagesByPeerID.removeValue(forKey: peerID)
return messages
}
mutating func prependPrivateMessages(_ messages: [BLEPendingPrivateMessage], for peerID: PeerID) {
guard !messages.isEmpty else { return }
privateMessagesByPeerID[peerID, default: []].insert(contentsOf: messages, at: 0)
}
mutating func appendTypedPayload(_ payload: Data, for peerID: PeerID) {
typedPayloadsByPeerID[peerID, default: []].append(payload)
}
mutating func takeTypedPayloads(for peerID: PeerID) -> [Data] {
let payloads = typedPayloadsByPeerID[peerID] ?? []
typedPayloadsByPeerID.removeValue(forKey: peerID)
return payloads
}
}
@@ -0,0 +1,137 @@
import BitFoundation
import Foundation
struct BLEOutboundFragmentPlan {
let fragmentPackets: [BitchatPacket]
let fragmentVersion: UInt8
let chunkSize: Int
let spacingMs: Int
var totalFragments: Int {
fragmentPackets.count
}
var shouldPauseScanning: Bool {
totalFragments > 4
}
}
enum BLEOutboundFragmentPlanner {
private static let minimumChunkSize = 64
private static let fragmentIDLength = 8
static func makePlan(
for request: BLEOutboundFragmentTransferRequest,
defaultChunkSize: Int,
bleMaxMTU: Int,
fragmentID: Data = randomFragmentID()
) -> BLEOutboundFragmentPlan? {
guard fragmentID.count == fragmentIDLength,
let fullData = request.packet.toBinaryData(padding: request.pad) else {
return nil
}
let sizing = sizingPolicy(
for: request.packet,
requestedMaxChunk: request.maxChunk,
defaultChunkSize: defaultChunkSize,
bleMaxMTU: bleMaxMTU
)
let chunks = stride(from: 0, to: fullData.count, by: sizing.chunkSize).map { offset in
Data(fullData[offset..<min(offset + sizing.chunkSize, fullData.count)])
}
guard !chunks.isEmpty else { return nil }
let fragmentRecipient: Data? = {
if let directedPeer = request.directedPeer {
return Data(hexString: directedPeer.id)
}
return request.packet.recipientID
}()
let fragmentPackets = chunks.enumerated().map { index, chunk in
makeFragmentPacket(
original: request.packet,
fragmentID: fragmentID,
index: index,
total: chunks.count,
fragmentData: chunk,
fragmentRecipient: fragmentRecipient,
fragmentVersion: sizing.fragmentVersion
)
}
return BLEOutboundFragmentPlan(
fragmentPackets: fragmentPackets,
fragmentVersion: sizing.fragmentVersion,
chunkSize: sizing.chunkSize,
spacingMs: spacingMs(for: request)
)
}
private static func sizingPolicy(
for packet: BitchatPacket,
requestedMaxChunk: Int?,
defaultChunkSize: Int,
bleMaxMTU: Int
) -> (fragmentVersion: UInt8, chunkSize: Int) {
var fragmentVersion: UInt8 = 1
var calculatedChunk = defaultChunkSize
if let route = packet.route, !route.isEmpty {
fragmentVersion = 2
let routeSize = 1 + (route.count * 8)
let overhead = 16 + 8 + 8 + routeSize + 13 + 16
calculatedChunk = max(minimumChunkSize, bleMaxMTU - overhead)
}
return (
fragmentVersion: fragmentVersion,
chunkSize: max(minimumChunkSize, requestedMaxChunk ?? calculatedChunk)
)
}
private static func makeFragmentPacket(
original packet: BitchatPacket,
fragmentID: Data,
index: Int,
total: Int,
fragmentData: Data,
fragmentRecipient: Data?,
fragmentVersion: UInt8
) -> BitchatPacket {
var payload = Data()
payload.append(fragmentID)
payload.append(contentsOf: withUnsafeBytes(of: UInt16(index).bigEndian) { Data($0) })
payload.append(contentsOf: withUnsafeBytes(of: UInt16(total).bigEndian) { Data($0) })
payload.append(packet.type)
payload.append(fragmentData)
return BitchatPacket(
type: MessageType.fragment.rawValue,
senderID: packet.senderID,
recipientID: fragmentRecipient,
timestamp: packet.timestamp,
payload: payload,
signature: nil,
ttl: packet.ttl,
version: fragmentVersion,
route: packet.route,
isRSR: packet.isRSR
)
}
private static func spacingMs(for request: BLEOutboundFragmentTransferRequest) -> Int {
if request.directedPeer != nil || request.packet.recipientID != nil {
return TransportConfig.bleFragmentSpacingDirectedMs
}
return TransportConfig.bleFragmentSpacingMs
}
private static func randomFragmentID() -> Data {
Data((0..<fragmentIDLength).map { _ in UInt8.random(in: 0...255) })
}
}
@@ -0,0 +1,181 @@
import BitFoundation
import Foundation
struct BLEOutboundFragmentTransferRequest {
let packet: BitchatPacket
let pad: Bool
let maxChunk: Int?
let directedPeer: PeerID?
let transferId: String?
var resolvedTransferId: String? {
guard packet.type == MessageType.fileTransfer.rawValue else { return nil }
return transferId ?? packet.payload.sha256Hex()
}
}
struct BLEOutboundFragmentTransferScheduler {
enum QueuePosition {
case front
case back
}
enum SubmitResult {
case start(request: BLEOutboundFragmentTransferRequest, reservedTransferId: String?)
case queued(request: BLEOutboundFragmentTransferRequest, transferId: String?, position: QueuePosition)
}
enum CancelResult {
case active(transferId: String, workItems: [DispatchWorkItem])
case pending(transferId: String)
case missing
}
enum SentResult: Equatable {
case progress(sentFragments: Int, totalFragments: Int)
case complete(sentFragments: Int, totalFragments: Int)
case missing
}
private struct ActiveTransferState {
let totalFragments: Int
var sentFragments: Int
var workItems: [DispatchWorkItem]
}
private var activeTransfers: [String: ActiveTransferState] = [:]
private var pendingTransfers: [BLEOutboundFragmentTransferRequest] = []
var activeCount: Int {
activeTransfers.count
}
var pendingCount: Int {
pendingTransfers.count
}
mutating func removeAll() -> [(id: String, workItems: [DispatchWorkItem])] {
let active = activeTransfers.map { ($0.key, $0.value.workItems) }
activeTransfers.removeAll()
pendingTransfers.removeAll()
return active
}
mutating func submit(
_ request: BLEOutboundFragmentTransferRequest,
maxConcurrentTransfers: Int
) -> SubmitResult {
guard let transferId = request.resolvedTransferId else {
return .start(request: request, reservedTransferId: nil)
}
guard activeTransfers.count < maxConcurrentTransfers else {
pendingTransfers.append(request)
return .queued(request: request, transferId: transferId, position: .back)
}
guard activeTransfers[transferId] == nil else {
pendingTransfers.insert(request, at: 0)
return .queued(request: request, transferId: transferId, position: .front)
}
activeTransfers[transferId] = ActiveTransferState(totalFragments: 0, sentFragments: 0, workItems: [])
return .start(request: request, reservedTransferId: transferId)
}
mutating func activateReservedTransfer(
id transferId: String,
totalFragments: Int,
workItems: [DispatchWorkItem]
) -> Bool {
guard activeTransfers[transferId] != nil else { return false }
activeTransfers[transferId] = ActiveTransferState(
totalFragments: totalFragments,
sentFragments: 0,
workItems: workItems
)
return true
}
mutating func updateWorkItems(_ workItems: [DispatchWorkItem], for transferId: String) -> Bool {
guard var state = activeTransfers[transferId] else { return false }
state.workItems = workItems
activeTransfers[transferId] = state
return true
}
mutating func releaseReservation(_ transferId: String) -> [DispatchWorkItem]? {
activeTransfers.removeValue(forKey: transferId)?.workItems
}
func isActive(_ transferId: String) -> Bool {
activeTransfers[transferId] != nil
}
mutating func cancelTransfer(_ transferId: String) -> CancelResult {
if let active = activeTransfers.removeValue(forKey: transferId) {
return .active(transferId: transferId, workItems: active.workItems)
}
if let pendingIndex = pendingTransfers.firstIndex(where: { $0.resolvedTransferId == transferId || $0.transferId == transferId }) {
pendingTransfers.remove(at: pendingIndex)
return .pending(transferId: transferId)
}
return .missing
}
mutating func markFragmentSent(transferId: String) -> SentResult {
guard var state = activeTransfers[transferId] else { return .missing }
state.sentFragments = min(state.sentFragments + 1, state.totalFragments)
let isComplete = state.sentFragments >= state.totalFragments
if isComplete {
activeTransfers.removeValue(forKey: transferId)
return .complete(sentFragments: state.sentFragments, totalFragments: state.totalFragments)
}
activeTransfers[transferId] = state
return .progress(sentFragments: state.sentFragments, totalFragments: state.totalFragments)
}
mutating func reservePendingStarts(maxConcurrentTransfers: Int) -> [SubmitResult] {
var availableSlots = max(0, maxConcurrentTransfers - activeTransfers.count)
guard availableSlots > 0, !pendingTransfers.isEmpty else { return [] }
var results: [SubmitResult] = []
var blockedFront: [BLEOutboundFragmentTransferRequest] = []
while availableSlots > 0, !pendingTransfers.isEmpty {
let request = pendingTransfers.removeFirst()
availableSlots -= 1
guard let transferId = request.resolvedTransferId else {
results.append(.start(request: request, reservedTransferId: nil))
continue
}
guard activeTransfers.count < maxConcurrentTransfers else {
pendingTransfers.insert(request, at: 0)
results.append(.queued(request: request, transferId: transferId, position: .front))
break
}
guard activeTransfers[transferId] == nil else {
blockedFront.append(request)
results.append(.queued(request: request, transferId: transferId, position: .front))
continue
}
activeTransfers[transferId] = ActiveTransferState(totalFragments: 0, sentFragments: 0, workItems: [])
results.append(.start(request: request, reservedTransferId: transferId))
}
if !blockedFront.isEmpty {
pendingTransfers.insert(contentsOf: blockedFront, at: 0)
}
return results
}
}
@@ -0,0 +1,87 @@
import BitFoundation
import Foundation
struct BLEOutboundLinkPlan: Equatable {
let directedPeerHint: PeerID?
let fragmentChunkSize: Int?
let selectedLinks: BLEFanoutSelection
let shouldSpoolDirectedPacket: Bool
}
enum BLEOutboundLinkPlanner {
static func plan(
packet: BitchatPacket,
dataCount: Int,
peripheralIDs: [String],
peripheralWriteLimits: [Int],
centralIDs: [String],
centralNotifyLimits: [Int],
ingressRecord: BLEIngressLinkRecord?,
excludedLinks: Set<BLEIngressLinkID>,
directedOnlyPeer: PeerID?
) -> BLEOutboundLinkPlan {
if let minLimit = minimumLinkLimit(
peripheralWriteLimits: peripheralWriteLimits,
centralNotifyLimits: centralNotifyLimits
), packet.type != MessageType.fragment.rawValue,
dataCount > minLimit {
return BLEOutboundLinkPlan(
directedPeerHint: directedPeerHint(for: packet, explicitPeer: directedOnlyPeer),
fragmentChunkSize: BLEOutboundPacketPolicy.fragmentChunkSize(forLinkLimit: minLimit),
selectedLinks: BLEFanoutSelection(peripheralIDs: [], centralIDs: []),
shouldSpoolDirectedPacket: false
)
}
let directedPeerHint = directedPeerHint(for: packet, explicitPeer: directedOnlyPeer)
let selectedLinks = BLEFanoutSelector.selectLinks(
peripheralIDs: peripheralIDs,
centralIDs: centralIDs,
ingressLink: ingressRecord?.link,
excludedLinks: excludedLinks,
directedPeerHint: directedPeerHint,
packetType: packet.type,
messageID: BLEOutboundPacketPolicy.messageID(for: packet)
)
return BLEOutboundLinkPlan(
directedPeerHint: directedPeerHint,
fragmentChunkSize: nil,
selectedLinks: selectedLinks,
shouldSpoolDirectedPacket: shouldSpoolDirectedPacket(
directedPeerHint: directedPeerHint,
selectedLinks: selectedLinks,
packetType: packet.type
)
)
}
static func directedPeerHint(for packet: BitchatPacket, explicitPeer: PeerID?) -> PeerID? {
if let explicitPeer { return explicitPeer }
if let recipient = PeerID(str: packet.recipientID?.hexEncodedString()), !recipient.isEmpty {
return recipient
}
return nil
}
static func minimumLinkLimit(peripheralWriteLimits: [Int], centralNotifyLimits: [Int]) -> Int? {
[peripheralWriteLimits.min(), centralNotifyLimits.min()]
.compactMap { $0 }
.min()
}
static func shouldSpoolDirectedPacket(
directedPeerHint: PeerID?,
selectedLinks: BLEFanoutSelection,
packetType: UInt8
) -> Bool {
guard directedPeerHint != nil,
selectedLinks.peripheralIDs.isEmpty,
selectedLinks.centralIDs.isEmpty else {
return false
}
return packetType == MessageType.noiseEncrypted.rawValue ||
packetType == MessageType.noiseHandshake.rawValue
}
}
@@ -0,0 +1,47 @@
import Foundation
struct BLEPendingNotification<Target> {
let data: Data
let targets: [Target]?
}
struct BLEOutboundNotificationBuffer<Target> {
enum EnqueueResult {
case enqueued(count: Int)
case full(count: Int)
}
private var notifications: [BLEPendingNotification<Target>] = []
var count: Int {
notifications.count
}
var isEmpty: Bool {
notifications.isEmpty
}
mutating func removeAll() {
notifications.removeAll()
}
mutating func enqueue(data: Data, targets: [Target]?, capCount: Int) -> EnqueueResult {
guard notifications.count < capCount else {
return .full(count: notifications.count)
}
notifications.append(BLEPendingNotification(data: data, targets: targets))
return .enqueued(count: notifications.count)
}
mutating func takeAll() -> [BLEPendingNotification<Target>] {
let pending = notifications
notifications.removeAll()
return pending
}
mutating func prepend(_ pending: [BLEPendingNotification<Target>]) {
guard !pending.isEmpty else { return }
notifications.insert(contentsOf: pending, at: 0)
}
}
@@ -0,0 +1,43 @@
import BitFoundation
import Foundation
enum BLEOutboundPacketPolicy {
private static let fragmentFrameOverhead = 13 + 8 + 8 + 13
static func messageID(for packet: BitchatPacket) -> String {
BLEIngressLinkRegistry.messageID(for: packet)
}
static func padsBLEFrame(for packetType: UInt8) -> Bool {
switch MessageType(rawValue: packetType) {
case .noiseEncrypted, .noiseHandshake:
return true
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer:
return false
}
}
static func priority(for packet: BitchatPacket, data: Data) -> BLEOutboundWritePriority {
guard let messageType = MessageType(rawValue: packet.type) else { return .low }
switch messageType {
case .fragment:
return .fragment(totalFragments: fragmentTotalCount(from: packet.payload))
case .fileTransfer:
return .fileTransfer
default:
return .high
}
}
static func fragmentChunkSize(forLinkLimit limit: Int) -> Int {
max(64, limit - fragmentFrameOverhead)
}
private static func fragmentTotalCount(from payload: Data) -> Int {
guard payload.count >= 12 else { return Int(UInt16.max) }
let totalHigh = Int(payload[10])
let totalLow = Int(payload[11])
let total = (totalHigh << 8) | totalLow
return max(total, 1)
}
}
@@ -0,0 +1,83 @@
import Foundation
struct BLEOutboundWritePriority: Comparable {
let level: Int
let suborder: Int
static let high = BLEOutboundWritePriority(level: 0, suborder: 0)
static func fragment(totalFragments: Int) -> BLEOutboundWritePriority {
BLEOutboundWritePriority(level: 1, suborder: max(1, min(totalFragments, Int(UInt16.max))))
}
static let fileTransfer = BLEOutboundWritePriority(level: 2, suborder: Int.max - 1)
static let low = BLEOutboundWritePriority(level: 2, suborder: Int.max)
static func < (lhs: BLEOutboundWritePriority, rhs: BLEOutboundWritePriority) -> Bool {
if lhs.level != rhs.level { return lhs.level < rhs.level }
return lhs.suborder < rhs.suborder
}
}
struct BLEPendingWrite {
let priority: BLEOutboundWritePriority
let data: Data
}
struct BLEOutboundWriteBuffer {
enum EnqueueResult {
case enqueued(trimmedBytes: Int, remainingBytes: Int)
case oversized(bytes: Int)
}
private var writesByPeripheralID: [String: [BLEPendingWrite]] = [:]
var peripheralIDs: [String] {
Array(writesByPeripheralID.keys)
}
mutating func removeAll() {
writesByPeripheralID.removeAll()
}
mutating func enqueue(
data: Data,
for peripheralID: String,
priority: BLEOutboundWritePriority,
capBytes: Int
) -> EnqueueResult {
guard data.count <= capBytes else {
return .oversized(bytes: data.count)
}
var queue = writesByPeripheralID[peripheralID] ?? []
let item = BLEPendingWrite(priority: priority, data: data)
let insertIndex = queue.firstIndex { item.priority < $0.priority } ?? queue.count
queue.insert(item, at: insertIndex)
var total = queue.reduce(0) { $0 + $1.data.count }
var trimmedBytes = 0
while total > capBytes && !queue.isEmpty {
let removed = queue.removeLast()
trimmedBytes += removed.data.count
total -= removed.data.count
}
writesByPeripheralID[peripheralID] = queue.isEmpty ? nil : queue
return .enqueued(trimmedBytes: trimmedBytes, remainingBytes: total)
}
mutating func takeAll(for peripheralID: String) -> [BLEPendingWrite] {
let items = writesByPeripheralID[peripheralID] ?? []
writesByPeripheralID[peripheralID] = nil
return items
}
mutating func prepend(_ items: [BLEPendingWrite], for peripheralID: String) {
guard !items.isEmpty else { return }
var existing = writesByPeripheralID[peripheralID] ?? []
existing.insert(contentsOf: items, at: 0)
writesByPeripheralID[peripheralID] = existing
}
}
@@ -0,0 +1,27 @@
import Foundation
enum BLEPacketFreshnessPolicy {
static let defaultMaxAgeSeconds: TimeInterval = 900
static func isBroadcastRecipient(_ recipientID: Data?) -> Bool {
guard let recipientID else { return true }
return recipientID.count == 8 && recipientID.allSatisfy { $0 == 0xFF }
}
static func isStale(
timestampMilliseconds: UInt64,
now: Date,
maxAgeSeconds: TimeInterval = defaultMaxAgeSeconds
) -> Bool {
let nowMilliseconds = UInt64(now.timeIntervalSince1970 * 1000)
let maxAgeMilliseconds = UInt64(maxAgeSeconds * 1000)
guard nowMilliseconds >= maxAgeMilliseconds else { return false }
return timestampMilliseconds < nowMilliseconds - maxAgeMilliseconds
}
static func ageSeconds(timestampMilliseconds: UInt64, now: Date) -> Double {
let nowMilliseconds = UInt64(now.timeIntervalSince1970 * 1000)
guard nowMilliseconds >= timestampMilliseconds else { return 0 }
return Double(nowMilliseconds - timestampMilliseconds) / 1000.0
}
}
@@ -0,0 +1,25 @@
import BitFoundation
import Foundation
struct BLEPeerEventDebouncer {
private var lastEmitByPeer: [PeerID: Date] = [:]
var count: Int {
lastEmitByPeer.count
}
@discardableResult
mutating func shouldEmit(peerID: PeerID, now: Date, minimumInterval: TimeInterval) -> Bool {
if let lastEmit = lastEmitByPeer[peerID],
now.timeIntervalSince(lastEmit) < minimumInterval {
return false
}
lastEmitByPeer[peerID] = now
return true
}
mutating func removeAll() {
lastEmitByPeer.removeAll()
}
}
@@ -0,0 +1,43 @@
import Foundation
enum BLEPeerPublishDecision: Equatable {
case publishNow
case schedule(delay: TimeInterval)
case skip
}
struct BLEPeerPublishCoalescer {
private var lastPublishAt: Date
private var publishPending: Bool
private let minimumInterval: TimeInterval
init(
lastPublishAt: Date = .distantPast,
publishPending: Bool = false,
minimumInterval: TimeInterval = 0.1
) {
self.lastPublishAt = lastPublishAt
self.publishPending = publishPending
self.minimumInterval = minimumInterval
}
mutating func requestPublish(now: Date) -> BLEPeerPublishDecision {
let elapsed = now.timeIntervalSince(lastPublishAt)
if elapsed >= minimumInterval {
lastPublishAt = now
return .publishNow
}
guard !publishPending else {
return .skip
}
publishPending = true
return .schedule(delay: minimumInterval - elapsed)
}
mutating func scheduledPublishFired(now: Date) {
lastPublishAt = now
publishPending = false
}
}
+211
View File
@@ -0,0 +1,211 @@
import BitFoundation
import Foundation
struct BLEPeerInfo: Equatable {
let peerID: PeerID
var nickname: String
var isConnected: Bool
var noisePublicKey: Data?
var signingPublicKey: Data?
var isVerifiedNickname: Bool
var lastSeen: Date
}
struct BLEPeerAnnounceUpdate: Equatable {
let isNewPeer: Bool
let wasDisconnected: Bool
let previousNickname: String?
}
struct BLEPeerLinkPresence: Equatable {
var hasPeripheral: Bool
var hasCentral: Bool
}
struct BLERemovedPeer: Equatable {
let peerID: PeerID
let nickname: String
}
struct BLEPeerConnectivityChanges: Equatable {
var disconnectedPeerIDs: [PeerID] = []
var removedPeers: [BLERemovedPeer] = []
}
struct BLEPeerRegistry {
private var peers: [PeerID: BLEPeerInfo] = [:]
var isEmpty: Bool {
peers.isEmpty
}
var count: Int {
peers.count
}
var peerIDs: [PeerID] {
Array(peers.keys)
}
var connectedCount: Int {
peers.values.filter(\.isConnected).count
}
var connectedPeerIDs: [PeerID] {
peers.values.compactMap { $0.isConnected ? $0.peerID : nil }
}
var connectedRoutingData: [Data] {
peers.values.filter(\.isConnected).compactMap { $0.peerID.routingData }
}
var snapshotByID: [PeerID: BLEPeerInfo] {
peers
}
mutating func removeAll() {
peers.removeAll()
}
func info(for peerID: PeerID) -> BLEPeerInfo? {
peers[peerID]
}
mutating func upsert(_ info: BLEPeerInfo) {
peers[info.peerID] = info
}
@discardableResult
mutating func remove(_ peerID: PeerID) -> BLEPeerInfo? {
peers.removeValue(forKey: peerID)
}
func isConnected(_ peerID: PeerID) -> Bool {
peers[peerID.toShort()]?.isConnected ?? false
}
func isReachable(_ peerID: PeerID, now: Date) -> Bool {
let shortID = peerID.toShort()
let meshAttached = connectedCount > 0
guard let info = peers[shortID] else { return false }
if info.isConnected { return true }
guard meshAttached else { return false }
let retention: TimeInterval = info.isVerifiedNickname
? TransportConfig.bleReachabilityRetentionVerifiedSeconds
: TransportConfig.bleReachabilityRetentionUnverifiedSeconds
return now.timeIntervalSince(info.lastSeen) <= retention
}
func nickname(for peerID: PeerID, connectedOnly: Bool) -> String? {
guard let peer = peers[peerID] else { return nil }
if connectedOnly && !peer.isConnected { return nil }
return peer.nickname
}
func fingerprint(for peerID: PeerID) -> String? {
peers[peerID]?.noisePublicKey?.sha256Fingerprint()
}
func displayNicknames(selfNickname: String) -> [PeerID: String] {
let connected = peers.filter { $0.value.isConnected }
let tuples = connected.map { ($0.key, $0.value.nickname, true) }
return PeerDisplayNameResolver.resolve(tuples, selfNickname: selfNickname)
}
func transportSnapshots(selfNickname: String) -> [TransportPeerSnapshot] {
let snapshot = Array(peers.values)
let resolvedNames = PeerDisplayNameResolver.resolve(
snapshot.map { ($0.peerID, $0.nickname, $0.isConnected) },
selfNickname: selfNickname
)
return snapshot.map { info in
TransportPeerSnapshot(
peerID: info.peerID,
nickname: resolvedNames[info.peerID] ?? info.nickname,
isConnected: info.isConnected,
noisePublicKey: info.noisePublicKey,
lastSeen: info.lastSeen
)
}
}
func collisionResolvedNickname(for peerID: PeerID, selfNickname: String) -> String? {
guard let info = peers[peerID], info.isVerifiedNickname else { return nil }
let hasCollision = peers.values.contains {
$0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID
} || selfNickname == info.nickname
return hasCollision ? info.nickname + "#" + String(peerID.id.prefix(4)) : info.nickname
}
mutating func markDisconnected(_ peerID: PeerID) {
guard var info = peers[peerID] else { return }
info.isConnected = false
peers[peerID] = info
}
mutating func updateLastSeen(_ peerID: PeerID, at date: Date) {
guard var peer = peers[peerID] else { return }
peer.lastSeen = date
peers[peerID] = peer
}
mutating func upsertVerifiedAnnounce(
peerID: PeerID,
nickname: String,
noisePublicKey: Data,
signingPublicKey: Data?,
isConnected: Bool,
now: Date
) -> BLEPeerAnnounceUpdate {
let existing = peers[peerID]
let update = BLEPeerAnnounceUpdate(
isNewPeer: existing == nil,
wasDisconnected: existing?.isConnected == false,
previousNickname: existing?.nickname
)
peers[peerID] = BLEPeerInfo(
peerID: existing?.peerID ?? peerID,
nickname: nickname,
isConnected: isConnected,
noisePublicKey: noisePublicKey,
signingPublicKey: signingPublicKey,
isVerifiedNickname: true,
lastSeen: now
)
return update
}
mutating func reconcileConnectivity(
now: Date,
linkStates: [PeerID: BLEPeerLinkPresence]
) -> BLEPeerConnectivityChanges {
var changes = BLEPeerConnectivityChanges()
for (peerID, peer) in Array(peers) {
let age = now.timeIntervalSince(peer.lastSeen)
let retention: TimeInterval = peer.isVerifiedNickname
? TransportConfig.bleReachabilityRetentionVerifiedSeconds
: TransportConfig.bleReachabilityRetentionUnverifiedSeconds
if peer.isConnected && age > TransportConfig.blePeerInactivityTimeoutSeconds {
let state = linkStates[peerID] ?? BLEPeerLinkPresence(hasPeripheral: false, hasCentral: false)
if !state.hasPeripheral && !state.hasCentral {
var updated = peer
updated.isConnected = false
peers[peerID] = updated
changes.disconnectedPeerIDs.append(peerID)
}
}
if !peer.isConnected && age > retention {
peers.removeValue(forKey: peerID)
changes.removedPeers.append(BLERemovedPeer(peerID: peerID, nickname: peer.nickname))
}
}
return changes
}
}
@@ -0,0 +1,60 @@
import BitFoundation
import Foundation
enum BLEPeerSenderDisplayName {
static func resolveKnownPeer(
peerID: PeerID,
localPeerID: PeerID,
localNickname: String,
peers: [PeerID: BLEPeerInfo],
allowConnectedUnverified: Bool
) -> String? {
if peerID == localPeerID {
return localNickname
}
guard let info = peers[peerID] else { return nil }
if info.isVerifiedNickname {
return collisionResolvedName(
displayName: info.nickname,
collisionNickname: info.nickname,
peerID: peerID,
localNickname: localNickname,
peers: peers
)
}
if allowConnectedUnverified, info.isConnected {
let displayName = info.nickname.isEmpty ? anonymousNickname(for: peerID) : info.nickname
return collisionResolvedName(
displayName: displayName,
collisionNickname: info.nickname,
peerID: peerID,
localNickname: localNickname,
peers: peers
)
}
return nil
}
static func anonymousNickname(for peerID: PeerID) -> String {
"anon" + String(peerID.id.prefix(4))
}
private static func collisionResolvedName(
displayName: String,
collisionNickname: String,
peerID: PeerID,
localNickname: String,
peers: [PeerID: BLEPeerInfo]
) -> String {
let hasCollision = peers.values.contains {
$0.isConnected && $0.nickname == collisionNickname && $0.peerID != peerID
} || localNickname == collisionNickname
guard hasCollision else { return displayName }
return displayName + "#" + String(peerID.id.prefix(4))
}
}

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