Compare commits

..
Author SHA1 Message Date
islam 53826d5145 Revive lost NotificationStreamAssembler changes 2025-10-15 04:03:23 +01:00
islam f58955f194 Explicitly list all Enum cases to get compile-time errors 2025-10-15 04:02:40 +01:00
islam 537737f521 Add the missing fileTransfer case 2025-10-15 04:02:40 +01:00
islam b58c71f777 Merge branch 'main' into ble 2025-10-15 01:27:32 +01:00
islam b4e2746be6 Convert new tests to Swift Testing 2025-10-15 01:25:48 +01:00
Islam 91040f7ed4 PeerID 23/n: ChatViewModel + its dependences (#801) 2025-10-15 00:56:27 +01:00
jackandislam 588d8fef0d 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.
2025-10-15 00:39:19 +01:00
jackandislam 8cd5a09a86 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.
2025-10-15 00:39:19 +01:00
jackandislam 121e1d246a 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.
2025-10-15 00:39:19 +01:00
jackandislam e5028e5e86 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).
2025-10-15 00:39:19 +01:00
jackandislam 6681ca11c3 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
2025-10-15 00:39:19 +01:00
jackandislam 81b5dd15c1 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
2025-10-15 00:39:19 +01:00
jackandislam 098f223906 Remove debug print statements from sendMessage 2025-10-15 00:39:19 +01:00
jackandislam d31dd8300f macOS: Focus message input on launch instead of nickname field 2025-10-15 00:39:19 +01:00
jackandislam 85b627945d 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
2025-10-15 00:39:19 +01:00
jackandislam 035ad175a7 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.
2025-10-15 00:39:19 +01:00
jackandislam b995a3fe4f 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.
2025-10-15 00:39:19 +01:00
jackandislam fb26db3bf0 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.
2025-10-15 00:39:19 +01:00
jackandislam de4bf0a471 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
2025-10-15 00:39:19 +01:00
jackandislam f493b50163 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.
2025-10-15 00:39:19 +01:00
jackandislam 4aa12c08e7 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.
2025-10-15 00:39:19 +01:00
jackandislam c2a0c86542 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.
2025-10-15 00:39:19 +01:00
jackandislam 073e22e126 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.
2025-10-15 00:39:19 +01:00
jackandislam 6533293f75 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.
2025-10-15 00:39:19 +01:00
jackandislam 757acef8d1 Fix binary protocol test fixtures 2025-10-15 00:39:18 +01:00
jackandislam db52c9463b Reset BLE assembler on stalled fragment trains 2025-10-15 00:37:41 +01:00
jackandislam b179d99cf8 Drop attachment ceilings to 1 MiB and bump release version 2025-10-15 00:37:41 +01:00
jackandislam 73d0867c18 Guard peer map reads on BLE message path 2025-10-15 00:37:41 +01:00
jackandislam 97f822b88d Restore BLE broadcasts when notify buffer is saturated 2025-10-15 00:37:41 +01:00
jackandislam 3f91d6510b Fix cleanupLocalFile lookup 2025-10-15 00:37:41 +01:00
jackandislam 2bb55cbe1a Resolve image/voice path handling 2025-10-15 00:37:41 +01:00
jackandislam 7fb93eb522 Hide absolute paths in media messages 2025-10-15 00:37:41 +01:00
jackandislam cb8b34f8ea Stub file transfer methods in mock 2025-10-15 00:37:41 +01:00
jackandislam b872113a4b Stub file transfer methods in mock 2025-10-15 00:37:41 +01:00
jackandislam de3795289d Use unique transfer identifiers 2025-10-15 00:37:41 +01:00
jackandislam bd37cc69a0 Preserve packet version when signing 2025-10-15 00:37:41 +01:00
jackandislam 788e21c4ea Fix CFMutableData handling 2025-10-15 00:37:40 +01:00
jackandislam c179e34c43 Target image byte size across platforms 2025-10-15 00:37:40 +01:00
jackandislam aa8b257e68 Normalize mac JPEG color space 2025-10-15 00:37:40 +01:00
jackandislam 7b4aeb506e Strip metadata in mac image encoding 2025-10-15 00:37:40 +01:00
jackandislam 5d5ed94952 Revert unsupported JPEG option 2025-10-15 00:37:40 +01:00
jackandislam 4945688eca Align mac image JPEG encoding 2025-10-15 00:37:40 +01:00
jackandislam 22bd975059 Allow user-selected write access 2025-10-15 00:37:40 +01:00
jackandislam d28b58ecb2 Fix image attachment detection 2025-10-15 00:37:40 +01:00
jackandislam e17163b3da Use save panel for mac image export 2025-10-15 00:37:40 +01:00
jackandislam 9346e62971 Keep processed images for outgoing messages 2025-10-15 00:37:40 +01:00
jackandislam a89fd153ee Lowercase image preview buttons 2025-10-15 00:37:40 +01:00
jackandislam 25bc737919 Reblur images via swipe 2025-10-15 00:37:40 +01:00
jackandislam 8218c12f69 Allow long-press reblur on images 2025-10-15 00:37:40 +01:00
jackandislam 60c2263a46 Use Photos picker on mac 2025-10-15 00:37:40 +01:00
jackandislam e2fcb44982 Restore mac photo picker access 2025-10-15 00:37:40 +01:00
jackandislam ebbb7b356f Display recording milliseconds 2025-10-15 00:37:40 +01:00
jackandislam 2d0f55ae84 Harden attachment transfer bookkeeping 2025-10-15 00:37:40 +01:00
jackandislam 51e8e4e51a Describe microphone usage 2025-10-15 00:37:40 +01:00
jackandislam fb251a3fa8 Permit mac media library access 2025-10-15 00:37:40 +01:00
jackandislam 4052ba581a Allow mac microphone access 2025-10-15 00:37:40 +01:00
jackandislam 235fefe4ab Enable mac attachment importers 2025-10-15 00:37:40 +01:00
jackandislam 8389961269 Fix compressed BLE file transfers 2025-10-15 00:37:40 +01:00
jackandislam a244c4084f Stop dropping partial BLE frames while assembling notifications 2025-10-15 00:37:40 +01:00
jackandislam ed320fb0ad Log incomplete BLE frames for debugging 2025-10-15 00:37:40 +01:00
jackandislam 2bdc1535c7 Add detailed logging for BLE fragment assembly 2025-10-15 00:37:40 +01:00
jackandislam 60a375469a Let BLE assembler accept large frames up to hard cap 2025-10-15 00:37:40 +01:00
jackandislam 640567b7e4 Add guard to drop oversized BLE notification assemblies 2025-10-15 00:37:40 +01:00
jackandislam f58bcaf615 Revert "Raise BLE notification buffer cap for large file transfers"
This reverts commit b624523af843475db84e4a846db8dcbe824ae408.
2025-10-15 00:37:40 +01:00
jackandislam 6e19995de2 Raise BLE notification buffer cap for large file transfers 2025-10-15 00:37:40 +01:00
jackandislam 78d72f5814 Allow file transfers from connected but unverified peers 2025-10-15 00:37:40 +01:00
jackandislam f145d13992 Copy imported files before sending to preserve access 2025-10-15 00:37:40 +01:00
jackandislam f607413caf Restore iOS file importer for attachments 2025-10-15 00:37:40 +01:00
jackandislam 47db836a22 Reduce vertical padding between chat rows 2025-10-15 00:37:40 +01:00
jackandislam aa7a5efe6a Tighten spacing above media message bubbles 2025-10-15 00:37:40 +01:00
jackandislam c8d196f106 Gracefully disable mac attachment pickers in sandbox 2025-10-15 00:37:40 +01:00
jackandislam 2cd90ff813 Add BLE file transfer support and media UX 2025-10-15 00:37:40 +01:00
Islamandjack 5267489fa2 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 00:33:35 +01:00
Islamandjack 790dcda8e5 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:33:35 +01:00
14 changed files with 434 additions and 317 deletions
+2 -5
View File
@@ -14,11 +14,8 @@ default:
# Check prerequisites
check:
@echo "Checking prerequisites..."
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install Xcode from App Store" && exit 1)
@xcode-select -p | grep -q "Xcode.app" || (echo "❌ Full Xcode required, not just command line tools. Install from App Store and run:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
@test -d "/Applications/Xcode.app" || (echo "❌ Xcode.app not found in Applications folder. Install from App Store" && exit 1)
@xcodebuild -version >/dev/null 2>&1 || (echo "❌ Xcode not properly configured. Try:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
@security find-identity -v -p codesigning | grep -q "Apple Development\|Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ Xcode not found. Install Xcode from App Store" && exit 1)
@security find-identity -v -p codesigning | grep -q "Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
@echo "✅ All prerequisites met"
# Backup original files
+6 -25
View File
@@ -33,32 +33,13 @@ final class GeoRelayDirectory {
/// Returns up to `count` relay URLs (wss://) closest to the given coordinate.
func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {
guard !entries.isEmpty, count > 0 else { return [] }
if entries.count <= count {
return entries
.sorted { a, b in
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
}
.map { "wss://\($0.host)" }
}
var best: [(entry: Entry, distance: Double)] = []
best.reserveCapacity(count)
for entry in entries {
let distance = haversineKm(lat, lon, entry.lat, entry.lon)
if best.count < count {
let idx = best.firstIndex { $0.distance > distance } ?? best.count
best.insert((entry, distance), at: idx)
} else if let worstDistance = best.last?.distance, distance < worstDistance {
let idx = best.firstIndex { $0.distance > distance } ?? best.count
best.insert((entry, distance), at: idx)
best.removeLast()
guard !entries.isEmpty else { return [] }
let sorted = entries
.sorted { a, b in
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
}
}
return best.map { "wss://\($0.entry.host)" }
.prefix(count)
return sorted.map { "wss://\($0.host)" }
}
// MARK: - Remote Fetch
+4 -16
View File
@@ -1142,19 +1142,9 @@ final class BLEService: NSObject {
// Check cumulative size before storing this fragment
let currentSize = incomingFragments[key]?.values.reduce(0) { $0 + $1.count } ?? 0
let assemblyLimit: Int = {
if originalType == MessageType.fileTransfer.rawValue {
// Allow headroom for TLV metadata and binary framing overhead.
return FileTransferLimits.maxFramedFileBytes
}
return FileTransferLimits.maxPayloadBytes
}()
guard currentSize + fragmentData.count <= assemblyLimit else {
guard currentSize + fragmentData.count <= FileTransferLimits.maxPayloadBytes else {
// Exceeds size limit - evict this assembly
SecureLogger.warning(
"🚫 Fragment assembly exceeds size limit (\(currentSize + fragmentData.count) bytes > \(assemblyLimit)), evicting",
category: .security
)
SecureLogger.warning("🚫 Fragment assembly exceeds size limit (\(currentSize + fragmentData.count) bytes), evicting", category: .security)
incomingFragments.removeValue(forKey: key)
fragmentMetadata.removeValue(forKey: key)
shouldReassemble = false
@@ -3814,20 +3804,18 @@ extension BLEService {
var accepted = false
var senderNickname: String = ""
// Snapshot peers to avoid concurrent mutation while iterating during nickname collision checks.
let peersSnapshot = collectionsQueue.sync { peers }
// If the packet is from ourselves (e.g., recovered via sync TTL==0), accept immediately
if peerID == myPeerID {
accepted = true
senderNickname = myNickname
}
else if let info = peersSnapshot[peerID], info.isVerifiedNickname {
else if let info = peers[peerID], info.isVerifiedNickname {
// Known verified peer path
accepted = true
senderNickname = info.nickname
// Handle nickname collisions
let hasCollision = peersSnapshot.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID } || (myNickname == info.nickname)
let hasCollision = peers.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID } || (myNickname == info.nickname)
if hasCollision {
senderNickname += "#" + String(peerID.id.prefix(4))
}
+19 -1
View File
@@ -65,11 +65,14 @@ final class CommandProcessor {
case "/unfav":
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
return handleFavorite(args, add: false)
//
case "/help", "/h":
return .error(message: "unknown command: \(cmd)")
default:
return .error(message: "unknown command: \(cmd)")
}
}
// MARK: - Command Handlers
private func handleMessage(_ args: String) -> CommandResult {
@@ -308,4 +311,19 @@ final class CommandProcessor {
}
}
private func handleHelp() -> CommandResult {
let helpText = """
commands:
/msg @name - start private chat
/who - list who's online
/clear - clear messages
/hug @name - send a hug
/slap @name - slap with a trout
/fav @name - add to favorites
/unfav @name - remove from favorites
/block @name - block
/unblock @name - unblock
"""
return .success(message: helpText)
}
}
+28
View File
@@ -27,6 +27,34 @@ final class KeychainManager: KeychainManagerProtocol {
private let service = BitchatApp.bundleID
private let appGroup = "group.\(BitchatApp.bundleID)"
private func isSandboxed() -> Bool {
#if os(macOS)
// More robust sandbox detection using multiple methods
// Method 1: Check environment variable (can be spoofed)
let environment = ProcessInfo.processInfo.environment
let hasEnvVar = environment["APP_SANDBOX_CONTAINER_ID"] != nil
// Method 2: Check if we can access a path outside sandbox
let homeDir = FileManager.default.homeDirectoryForCurrentUser
let testPath = homeDir.appendingPathComponent("../../../tmp/bitchat_sandbox_test_\(UUID().uuidString)")
let canWriteOutsideSandbox = FileManager.default.createFile(atPath: testPath.path, contents: nil, attributes: nil)
if canWriteOutsideSandbox {
try? FileManager.default.removeItem(at: testPath)
}
// Method 3: Check container path
let containerPath = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first?.path ?? ""
let hasContainerPath = containerPath.contains("/Containers/")
// If any method indicates sandbox, we consider it sandboxed
return hasEnvVar || !canWriteOutsideSandbox || hasContainerPath
#else
// iOS is always sandboxed
return true
#endif
}
// MARK: - Identity Keys
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
-10
View File
@@ -8,16 +8,6 @@ enum FileTransferLimits {
static let maxVoiceNoteBytes: Int = 1 * 1024 * 1024 // 1 MiB
/// Compressed images after downscaling should comfortably fit under this budget.
static let maxImageBytes: Int = 1 * 1024 * 1024 // 1 MiB
/// Worst-case size once TLV metadata and binary packet framing are included for the largest payloads.
static let maxFramedFileBytes: Int = {
let maxMetadataBytes = Int(UInt16.max) * 2 // fileName + mimeType TLVs
let tlvEnvelopeOverhead = 18 + maxMetadataBytes // TLV tags + lengths + metadata bytes
let binaryEnvelopeOverhead = BinaryProtocol.v2HeaderSize
+ BinaryProtocol.senderIDSize
+ BinaryProtocol.recipientIDSize
+ BinaryProtocol.signatureSize
return maxPayloadBytes + tlvEnvelopeOverhead + binaryEnvelopeOverhead
}()
static func isValidPayload(_ size: Int) -> Bool {
size <= maxPayloadBytes
+191 -52
View File
@@ -198,37 +198,20 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Persistent recent content map (LRU) to speed near-duplicate checks
private var contentLRUMap: [String: Date] = [:]
private var contentLRUOrder: [String] = []
private var contentLRUHead = 0
private let contentLRUCap = TransportConfig.contentLRUCap
private func recordContentKey(_ key: String, timestamp: Date) {
if contentLRUMap[key] == nil { contentLRUOrder.append(key) }
contentLRUMap[key] = timestamp
trimContentLRUIfNeeded()
}
private func trimContentLRUIfNeeded() {
let activeCount = contentLRUOrder.count - contentLRUHead
guard activeCount > contentLRUCap else { return }
let overflow = activeCount - contentLRUCap
for _ in 0..<overflow {
guard let victim = popOldestContentKey() else { break }
contentLRUMap.removeValue(forKey: victim)
if contentLRUOrder.count > contentLRUCap {
let overflow = contentLRUOrder.count - contentLRUCap
for _ in 0..<overflow {
if let victim = contentLRUOrder.first {
contentLRUOrder.removeFirst()
contentLRUMap.removeValue(forKey: victim)
}
}
}
}
private func popOldestContentKey() -> String? {
guard contentLRUHead < contentLRUOrder.count else { return nil }
let victim = contentLRUOrder[contentLRUHead]
contentLRUHead += 1
// Periodically compact the backing storage to avoid unbounded growth.
if contentLRUHead >= 32 && contentLRUHead * 2 >= contentLRUOrder.count {
contentLRUOrder.removeFirst(contentLRUHead)
contentLRUHead = 0
}
return victim
}
// MARK: - Published Properties
@Published var messages: [BitchatMessage] = []
@@ -366,7 +349,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// PeerManager replaced by UnifiedPeerService
private var processedNostrEvents = Set<String>() // Simple deduplication
private var processedNostrEventOrder: [String] = []
private var processedNostrEventHead = 0
private let maxProcessedNostrEvents = TransportConfig.uiProcessedNostrEventsCap
private let userDefaults = UserDefaults.standard
private let keychain: KeychainManagerProtocol
@@ -2193,35 +2175,30 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
return "anon#\(suffix)"
}
// Helper: display name for current active channel (for notifications)
private func activeChannelDisplayName() -> String {
switch activeChannel {
case .mesh:
return "#mesh"
case .location(let ch):
return "#\(ch.geohash)"
}
}
// Dedup helper with small memory cap
private func recordProcessedEvent(_ id: String) {
processedNostrEvents.insert(id)
processedNostrEventOrder.append(id)
trimProcessedNostrEventsIfNeeded()
}
private func trimProcessedNostrEventsIfNeeded() {
let activeCount = processedNostrEventOrder.count - processedNostrEventHead
guard activeCount > maxProcessedNostrEvents else { return }
let overflow = activeCount - maxProcessedNostrEvents
for _ in 0..<overflow {
guard let old = popOldestProcessedEvent() else { break }
processedNostrEvents.remove(old)
if processedNostrEventOrder.count > maxProcessedNostrEvents {
let overflow = processedNostrEventOrder.count - maxProcessedNostrEvents
for _ in 0..<overflow {
if let old = processedNostrEventOrder.first {
processedNostrEventOrder.removeFirst()
processedNostrEvents.remove(old)
}
}
}
}
private func popOldestProcessedEvent() -> String? {
guard processedNostrEventHead < processedNostrEventOrder.count else { return nil }
let value = processedNostrEventOrder[processedNostrEventHead]
processedNostrEventHead += 1
if processedNostrEventHead >= 32 && processedNostrEventHead * 2 >= processedNostrEventOrder.count {
processedNostrEventOrder.removeFirst(processedNostrEventHead)
processedNostrEventHead = 0
}
return value
}
/// Sends an encrypted private message to a specific peer.
/// - Parameters:
@@ -3820,6 +3797,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
if let spid = message.senderPeerID {
// In geohash channels, compare against our per-geohash nostr short ID
if case .location(let ch) = activeChannel, spid.isGeoChat {
// Use cached identity to avoid crypto during rendering
let myGeo: NostrIdentity? = {
if let cached = cachedGeohashIdentity, cached.geohash == ch.geohash {
return cached.identity
@@ -3831,7 +3809,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
}
return nil
}()
if let myGeo {
if let myGeo = myGeo {
return spid == PeerID(nostr: myGeo.publicKeyHex)
}
}
@@ -5434,10 +5412,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
private func parseMentions(from content: String) -> [String] {
// Allow optional disambiguation suffix '#abcd' for duplicate nicknames
let regex = Regexes.mention
let pattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)"
let regex = try? NSRegularExpression(pattern: pattern, options: [])
let nsContent = content as NSString
let nsLen = nsContent.length
let matches = regex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen))
let matches = regex?.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) ?? []
var mentions: [String] = []
let peerNicknames = meshService.getPeerNicknames()
@@ -5948,6 +5927,61 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
)
}
@MainActor
private func handleNostrAcknowledgment(content: String, from senderPubkey: String) {
// Parse ACK format: "ACK:TYPE:MESSAGE_ID"
let parts = content.split(separator: ":", maxSplits: 2)
guard parts.count >= 3 else {
SecureLogger.warning("⚠️ Invalid ACK format: \(content)", category: .session)
return
}
let ackType = String(parts[1])
let messageId = String(parts[2])
// Check if we've already processed this ACK
let ackKey = "\(messageId):\(ackType):\(senderPubkey)"
if processedNostrAcks.contains(ackKey) {
// Skip duplicate ACK
return
}
processedNostrAcks.insert(ackKey)
SecureLogger.debug("📨 Received \(ackType) ACK for message \(messageId.prefix(16))... from \(senderPubkey.prefix(16))...", category: .session)
// Verify the sender has a valid Noise key
guard findNoiseKey(for: senderPubkey) != nil else {
// Cannot find Noise key for ACK sender
return
}
// Find and update the message status in ALL private chats (both stable and ephemeral)
var messageFound = false
for (chatPeerID, messages) in privateChats {
if let index = messages.firstIndex(where: { $0.id == messageId }) {
// Update delivery status based on ACK type
switch ackType {
case "DELIVERED":
privateChats[chatPeerID]?[index].deliveryStatus = .delivered(to: "recipient", at: Date())
case "READ":
privateChats[chatPeerID]?[index].deliveryStatus = .read(by: "recipient", at: Date())
default:
SecureLogger.warning("⚠️ Unknown ACK type: \(ackType)", category: .session)
}
messageFound = true
SecureLogger.info("✅ Updated message \(messageId.prefix(16))... status to \(ackType) in chat \(chatPeerID.id.prefix(16))...", category: .session)
// Don't break - continue to update in all chats where this message exists
}
}
if messageFound {
objectWillChange.send()
} else {
SecureLogger.warning("⚠️ Could not find message \(messageId) to update status from ACK", category: .session)
}
}
// MARK: - Base64URL utils
private static func base64URLDecode(_ s: String) -> Data? {
var str = s.replacingOccurrences(of: "-", with: "+")
@@ -6006,6 +6040,111 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
}
}
@MainActor
private func handleNostrMessageFromUnknownSender(
messageId: String,
content: String,
senderPubkey: String,
senderNickname: String? = nil,
timestamp: Date
) {
// Check if we already have this message in local storage
for (_, messages) in privateChats {
if messages.contains(where: { $0.id == messageId }) {
return // Skipping duplicate message
}
}
// Check if we've read this message before (in a previous session)
let wasReadBefore = sentReadReceipts.contains(messageId)
// Try to find sender by checking all known peers for nickname matches
// This is a fallback when we receive Nostr messages from someone not in favorites
// For now, create a temporary peer ID based on Nostr pubkey
// This allows the message to be displayed even without Noise key mapping
let tempPeerID = PeerID(nostr_: senderPubkey)
// Check if we're viewing this unknown sender's chat
let isViewingThisChat = selectedPrivateChatPeer == tempPeerID
// Check if message is recent (less than 30 seconds old)
let messageAgeSeconds = Date().timeIntervalSince(timestamp)
let isRecentMessage = messageAgeSeconds < 30
// Determine if we should mark as unread BEFORE adding to chats
// During startup phase, only block OLD messages from being marked as unread
// Recent messages should always be marked as unread if not previously read
let shouldMarkAsUnread = !wasReadBefore && !isViewingThisChat && (isRecentMessage || !isStartupPhase)
// Use provided nickname or try to extract from previous messages
var finalSenderNickname = senderNickname ?? "Unknown"
// If no nickname provided, check if we have any previous messages from this Nostr key
if senderNickname == nil {
for (_, messages) in privateChats {
if let previousMessage = messages.first(where: {
$0.senderPeerID == tempPeerID
}) {
finalSenderNickname = previousMessage.sender
break
}
}
}
// Create the message
let message = BitchatMessage(
id: messageId,
sender: finalSenderNickname,
content: content,
timestamp: timestamp,
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: nickname,
senderPeerID: tempPeerID,
mentions: nil,
deliveryStatus: .delivered(to: nickname, at: Date())
)
// Store in private chats
if privateChats[tempPeerID] == nil {
privateChats[tempPeerID] = []
}
privateChats[tempPeerID]?.append(message)
// For unknown senders (no Noise key), skip sending Nostr ACKs
// Handle based on read status
if wasReadBefore {
// Message was read in a previous session - don't mark as unread or notify
// Not marking previously-read message as unread
} else if isViewingThisChat {
// Viewing this chat - mark as read
// No read ACKs for unknown senders
} else {
// Not viewing and not previously read
// Use pre-calculated shouldMarkAsUnread to avoid UI flicker
if shouldMarkAsUnread {
unreadPrivateMessages.insert(tempPeerID)
// Only notify if it's a recent message
if isRecentMessage {
NotificationService.shared.sendPrivateMessageNotification(
from: finalSenderNickname,
message: content,
peerID: tempPeerID.id
)
} else {
// Not notifying for old message
}
}
// Not notifying for old message
}
SecureLogger.info("📬 Stored Nostr message from unknown sender \(finalSenderNickname) in temporary peer \(tempPeerID)", category: .session)
}
@MainActor
private func findNoiseKey(for nostrPubkey: String) -> Data? {
// Convert hex to npub if needed for comparison
+23 -76
View File
@@ -322,8 +322,8 @@ struct ContentView: View {
private func messagesView(privatePeer: String?, isAtBottom: Binding<Bool>) -> some View {
let messages: [BitchatMessage] = {
if let peerID = PeerID(str: privatePeer) {
return viewModel.getPrivateChatMessages(for: peerID)
if let privatePeer {
return viewModel.getPrivateChatMessages(for: PeerID(str: privatePeer))
}
return viewModel.messages
}()
@@ -765,55 +765,26 @@ struct ContentView: View {
}
private func handleOpenURL(_ url: URL) {
guard url.scheme == "bitchat" else { return }
switch url.host {
case "user":
let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let peerID = PeerID(str: id.removingPercentEncoding ?? id)
selectedMessageSenderID = peerID.id
guard url.scheme == "bitchat", url.host == "user" else { return }
let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let peerID = PeerID(str: id.removingPercentEncoding ?? id)
selectedMessageSenderID = peerID.id
if peerID.isGeoDM || peerID.isGeoChat {
selectedMessageSender = viewModel.geohashDisplayName(for: peerID)
} else if let name = viewModel.meshService.peerNickname(peerID: peerID) {
if peerID.isGeoDM || peerID.isGeoChat {
selectedMessageSender = viewModel.geohashDisplayName(for: peerID)
} else {
if let name = viewModel.meshService.peerNickname(peerID: peerID) {
selectedMessageSender = name
} else {
selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender
}
}
if viewModel.isSelfSender(peerID: peerID, displayName: selectedMessageSender) {
selectedMessageSender = nil
selectedMessageSenderID = nil
} else {
showMessageActions = true
}
case "geohash":
let gh = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased()
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
guard (2...12).contains(gh.count), gh.allSatisfy({ allowed.contains($0) }) else { return }
func levelForLength(_ len: Int) -> GeohashChannelLevel {
switch len {
case 0...2: return .region
case 3...4: return .province
case 5: return .city
case 6: return .neighborhood
case 7: return .block
default: return .block
}
}
let level = levelForLength(gh.count)
let channel = GeohashChannel(level: level, geohash: gh)
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == gh }
if !inRegional && !LocationChannelManager.shared.availableChannels.isEmpty {
LocationChannelManager.shared.markTeleported(for: gh, true)
}
LocationChannelManager.shared.select(ChannelID.location(channel))
default:
return
if viewModel.isSelfSender(peerID: peerID, displayName: selectedMessageSender) {
selectedMessageSender = nil
selectedMessageSenderID = nil
} else {
showMessageActions = true
}
}
@@ -821,8 +792,8 @@ struct ContentView: View {
privatePeer: String?,
isAtBottom: Binding<Bool>) {
let targetID: String? = {
if let peer = PeerID(str: privatePeer),
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
if let peer = privatePeer,
let last = viewModel.getPrivateChatMessages(for: PeerID(str: peer)).suffix(300).last?.id {
return "dm:\(peer)|\(last)"
}
let contextKey: String = {
@@ -831,41 +802,17 @@ struct ContentView: View {
case .location(let ch): return "geo:\(ch.geohash)"
}
}()
if let last = viewModel.messages.suffix(300).last?.id {
return "\(contextKey)|\(last)"
}
if let last = viewModel.messages.suffix(300).last?.id { return "\(contextKey)|\(last)" }
return nil
}()
isAtBottom.wrappedValue = true
guard let target = targetID else { return }
DispatchQueue.main.async {
if let targetID {
proxy.scrollTo(targetID, anchor: .bottom)
}
proxy.scrollTo(target, anchor: .bottom)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
let secondTarget: String? = {
if let peer = PeerID(str: privatePeer),
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
return "dm:\(peer)|\(last)"
}
let contextKey: String = {
switch locationManager.selectedChannel {
case .mesh: return "mesh"
case .location(let ch): return "geo:\(ch.geohash)"
}
}()
if let last = viewModel.messages.suffix(300).last?.id {
return "\(contextKey)|\(last)"
}
return nil
}()
if let secondTarget {
proxy.scrollTo(secondTarget, anchor: .bottom)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
proxy.scrollTo(target, anchor: .bottom)
}
}
// MARK: - Actions
@@ -92,62 +92,6 @@ struct FragmentationTests {
#expect(capture.publicMessages.count == 1)
#expect(capture.publicMessages.first?.content.count == 2048)
}
@Test("Max-sized file transfer survives reassembly")
func maxSizedFileTransferSurvivesReassembly() async throws {
let ble = BLEService(
keychain: mockKeychain,
idBridge: idBridge,
identityManager: mockIdentityManager
)
let capture = CaptureDelegate()
ble.delegate = capture
let remoteID = PeerID(str: "CAFEBABECAFEBABE")
let fileContent = Data(repeating: 0x42, count: FileTransferLimits.maxPayloadBytes)
let filePacket = BitchatFilePacket(
fileName: "limit.bin",
fileSize: UInt64(fileContent.count),
mimeType: "application/octet-stream",
content: fileContent
)
let encoded = try #require(filePacket.encode(), "File packet encoding failed")
let packet = BitchatPacket(
type: MessageType.fileTransfer.rawValue,
senderID: Data(hexString: remoteID.id) ?? Data(),
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: encoded,
signature: nil,
ttl: 7,
version: 2
)
let fragments = fragmentPacket(packet, fragmentSize: 4096, pad: false)
#expect(!fragments.isEmpty)
for (i, fragment) in fragments.enumerated() {
let delay = 5 * Double(i) * 0.001
Task {
try await sleep(delay)
ble._test_handlePacket(fragment, fromPeerID: remoteID)
}
}
try await sleep(1.0)
let message = try #require(capture.receivedMessages.first, "Expected file transfer message")
#expect(message.content.hasPrefix("[file]"))
if let fileName = message.content.split(separator: " ").last {
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let filesRoot = base.appendingPathComponent("files", isDirectory: true)
let incoming = filesRoot.appendingPathComponent("files/incoming", isDirectory: true)
let url = incoming.appendingPathComponent(String(fileName))
try? FileManager.default.removeItem(at: url)
}
}
@Test("Invalid fragment header is ignored")
func invalidFragmentHeaderIsIgnored() async throws {
@@ -198,10 +142,7 @@ struct FragmentationTests {
extension FragmentationTests {
private final class CaptureDelegate: BitchatDelegate {
var publicMessages: [(peerID: PeerID, nickname: String, content: String)] = []
var receivedMessages: [BitchatMessage] = []
func didReceiveMessage(_ message: BitchatMessage) {
receivedMessages.append(message)
}
func didReceiveMessage(_ message: BitchatMessage) {}
func didConnectToPeer(_ peerID: PeerID) {}
func didDisconnectFromPeer(_ peerID: PeerID) {}
func didUpdatePeerList(_ peers: [PeerID]) {}
@@ -232,8 +173,8 @@ extension FragmentationTests {
}
// Helper: fragment a packet using the same header format BLEService expects
private func fragmentPacket(_ packet: BitchatPacket, fragmentSize: Int, fragmentID: Data? = nil, pad: Bool = true) -> [BitchatPacket] {
guard let fullData = packet.toBinaryData(padding: pad) else { return [] }
private func fragmentPacket(_ packet: BitchatPacket, fragmentSize: Int, fragmentID: Data? = nil) -> [BitchatPacket] {
let fullData = packet.toBinaryData() ?? Data()
let fid = fragmentID ?? Data((0..<8).map { _ in UInt8.random(in: 0...255) })
let chunks: [Data] = stride(from: 0, to: fullData.count, by: fragmentSize).map { off in
Data(fullData[off..<min(off + fragmentSize, fullData.count)])
@@ -96,7 +96,7 @@ struct NotificationStreamAssemblerTests {
#expect(decoded.timestamp == packet.timestamp)
}
func testAssemblesCompressedLargeFrame() throws {
@Test func assemblesCompressedLargeFrame() throws {
var assembler = NotificationStreamAssembler()
// Keep the fixture below FileTransferLimits.maxPayloadBytes so encoding succeeds while still exercising compression.
@@ -108,7 +108,6 @@ struct NotificationStreamAssemblerTests {
content: largeContent
)
let tlvPayload = try #require(filePacket.encode(), "Failed to encode file packet")
let senderID = Data(repeating: 0xAA, count: BinaryProtocol.senderIDSize)
let packet = BitchatPacket(
type: MessageType.fileTransfer.rawValue,
@@ -125,7 +124,7 @@ struct NotificationStreamAssemblerTests {
#expect(BinaryProtocol.Offsets.flags < frame.count)
let flags = frame[frame.startIndex + BinaryProtocol.Offsets.flags]
#expect((flags & BinaryProtocol.Flags.isCompressed) != 0, "Frame should be compressed for large payloads")
#expect(flags & BinaryProtocol.Flags.isCompressed != 0, "Frame should be compressed for large payloads")
let splitIndex = min(4096, frame.count / 2)
var result = assembler.append(frame.prefix(splitIndex))
@@ -134,7 +133,7 @@ struct NotificationStreamAssemblerTests {
result = assembler.append(frame.suffix(from: splitIndex))
#expect(result.frames.count == 1)
#expect(result.droppedPrefixes.isEmpty)
#expect(result.reset == false)
#expect(!result.reset)
let assembled = try #require(result.frames.first, "Missing assembled frame")
#expect(assembled.count == frame.count)
@@ -68,9 +68,10 @@ struct BinaryProtocolTests {
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with large payload")
// The encoded size should be smaller than uncompressed due to compression
let headerSize = try #require(BinaryProtocol.headerSize(for: packet.version), "Invalid packet version")
let headerSize = try #require(BinaryProtocol.headerSize(for: packet.version), "Invalid version")
let uncompressedSize = headerSize + BinaryProtocol.senderIDSize + largePayload.count
#expect(encodedData.count < uncompressedSize, "Compressed packet should be smaller than uncompressed form")
#expect(encodedData.count < uncompressedSize)
// Decode and verify
let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode compressed packet")
@@ -6,13 +6,11 @@
// For more information, see <https://unlicense.org>
//
#if canImport(os.log)
import os.log
#endif
public extension OSLog {
private static let subsystem = "chat.bitchat"
static let noise = OSLog(subsystem: subsystem, category: "noise")
static let encryption = OSLog(subsystem: subsystem, category: "encryption")
static let keychain = OSLog(subsystem: subsystem, category: "keychain")
@@ -7,53 +7,7 @@
//
import Foundation
#if canImport(os.log)
import os.log
#else
public struct OSLog {
public let subsystem: String
public let category: String
public init(subsystem: String, category: String) {
self.subsystem = subsystem
self.category = category
}
}
public struct OSLogType: CustomStringConvertible {
private let label: String
private init(_ label: String) {
self.label = label
}
public var description: String { label }
public static let debug = OSLogType("debug")
public static let info = OSLogType("info")
public static let `default` = OSLogType("default")
public static let error = OSLogType("error")
public static let fault = OSLogType("fault")
}
@usableFromInline
let secureLoggerFallbackFormatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
@usableFromInline
func os_log(_ message: StaticString, log: OSLog, type: OSLogType, _ args: CVarArg...) {
let rawFormat = String(describing: message)
let format = rawFormat
.replacingOccurrences(of: "%{public}@", with: "%@")
.replacingOccurrences(of: "%{private}@", with: "%@")
let formatted = String(format: format, arguments: args)
let timestamp = secureLoggerFallbackFormatter.string(from: Date())
print("[\(timestamp)] [\(log.subsystem)::\(log.category)] [\(type.description)] \(formatted)")
}
#endif
/// Centralized security-aware logging framework
/// Provides safe logging that filters sensitive data and security events
+151 -15
View File
@@ -1,23 +1,11 @@
import BitLogger
import Foundation
#if canImport(Network)
import Network
#endif
#if canImport(Darwin)
import Darwin
#elseif canImport(Glibc)
import Glibc
#endif
#if !canImport(Network)
private final class NWPathMonitor {
var pathUpdateHandler: ((Any) -> Void)?
func start(queue: DispatchQueue) {
// Path monitoring is unavailable on this platform; nothing to do.
}
}
#endif
// Declare C entrypoint for Tor when statically linked from an xcframework.
@_silgen_name("tor_main")
private func tor_main_c(_ argc: Int32, _ argv: UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>?) -> Int32
// Preferred: tiny C glue that uses Tor's embedding API (tor_api.h)
@_silgen_name("tor_host_start")
@@ -298,6 +286,150 @@ public final class TorManager: ObservableObject {
}
}
// MARK: - Dynamic loader path (no Swift module required)
/// Attempt to locate an embedded tor framework binary and launch Tor via `tor_run_main`.
/// Returns true if the attempt started and port probing was scheduled.
private func startTorViaDlopen() -> Bool {
guard let fwURL = frameworkBinaryURL() else {
SecureLogger.warning("TorManager: no embedded tor framework found", category: .session)
return false
}
// Load the library
let mode = RTLD_NOW | RTLD_LOCAL
SecureLogger.info("TorManager: dlopen(\(fwURL.lastPathComponent))…", category: .session)
guard let handle = dlopen(fwURL.path, mode) else {
let err = String(cString: dlerror())
self.lastError = NSError(domain: "TorManager", code: -10, userInfo: [NSLocalizedDescriptionKey: "dlopen failed: \(err)"])
self.isStarting = false
return false
}
// Resolve tor_main(argc, argv)
typealias TorMainType = @convention(c) (Int32, UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>?) -> Int32
guard let sym = dlsym(handle, "tor_main") else {
// Keep handle open but report error
let err = String(cString: dlerror())
self.lastError = NSError(domain: "TorManager", code: -11, userInfo: [NSLocalizedDescriptionKey: "dlsym tor_main failed: \(err)"])
self.isStarting = false
return false
}
let torMain = unsafeBitCast(sym, to: TorMainType.self)
self._dlHandle = handle
// Prepare args: tor -f <torrc>
var argv: [String] = ["tor"]
if let torrc = torrcURL()?.path {
argv.append(contentsOf: ["-f", torrc])
}
// Run Tor on a background thread to avoid blocking the main actor
SecureLogger.info("TorManager: launching tor_main with torrc", category: .session)
let argc = Int32(argv.count)
DispatchQueue.global(qos: .utility).async {
// Build stable C argv in this thread
let cStrings: [UnsafeMutablePointer<CChar>?] = argv.map { strdup($0) }
let cArgv = UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>.allocate(capacity: cStrings.count + 1)
for i in 0..<cStrings.count { cArgv[i] = cStrings[i] }
cArgv[cStrings.count] = nil
_ = torMain(argc, cArgv)
// Free args after exit (Tor usually never returns)
for ptr in cStrings.compactMap({ $0 }) { free(ptr) }
cArgv.deallocate()
}
// Start control-port monitor and probe readiness asynchronously
startControlMonitorIfNeeded()
Task.detached(priority: .userInitiated) { [weak self] in
guard let self else { return }
let ready = await self.waitForSocksReady(timeout: 60.0)
await MainActor.run {
self.socksReady = ready
if !ready {
self.lastError = NSError(domain: "TorManager", code: -12, userInfo: [NSLocalizedDescriptionKey: "Tor SOCKS not reachable after dlopen start"])
SecureLogger.error("TorManager: SOCKS not reachable (timeout)", category: .session)
} else {
SecureLogger.info("TorManager: SOCKS ready at \(self.socksHost):\(self.socksPort)", category: .session)
}
// isStarting will be cleared when bootstrap reaches 100%
}
}
return true
}
private var _dlHandle: UnsafeMutableRawPointer?
private func frameworkBinaryURL() -> URL? {
// Try common embedded locations for the framework binary name
let candidates = [
"tor-nolzma.framework/tor-nolzma",
"Tor.framework/Tor",
]
if let base = Bundle.main.privateFrameworksURL {
for rel in candidates {
let url = base.appendingPathComponent(rel)
if FileManager.default.fileExists(atPath: url.path) { return url }
}
}
// For macOS apps, also try Contents/Frameworks explicitly
#if os(macOS)
if let appURL = Bundle.main.bundleURL as URL?,
let frameworksURL = Optional(appURL.appendingPathComponent("Contents/Frameworks", isDirectory: true)) {
for rel in candidates {
let url = frameworksURL.appendingPathComponent(rel)
if FileManager.default.fileExists(atPath: url.path) { return url }
}
}
#endif
return nil
}
// MARK: - Static-link path (no module import)
private func startTorViaLinkedSymbol() -> Bool {
// Attempt to start tor_run_main directly (statically linked). If the
// symbol is not present at link-time, builds will fail which is
// expected when the xcframework is absent.
var argv: [String] = ["tor"]
if let torrc = torrcURL()?.path { argv.append(contentsOf: ["-f", torrc]) }
SecureLogger.info("TorManager: starting tor_main (static)", category: .session)
let argc = Int32(argv.count)
DispatchQueue.global(qos: .utility).async {
// Build stable C argv in this thread
let cStrings: [UnsafeMutablePointer<CChar>?] = argv.map { strdup($0) }
let cArgv = UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>.allocate(capacity: cStrings.count + 1)
for i in 0..<cStrings.count { cArgv[i] = cStrings[i] }
cArgv[cStrings.count] = nil
_ = tor_main_c(argc, cArgv)
// If tor_main ever returns, free memory
for ptr in cStrings.compactMap({ $0 }) { free(ptr) }
cArgv.deallocate()
}
// Start control monitor early
startControlMonitorIfNeeded()
Task.detached(priority: .userInitiated) { [weak self] in
guard let self else { return }
let ready = await self.waitForSocksReady(timeout: 60.0)
await MainActor.run {
self.socksReady = ready
if ready {
SecureLogger.info("TorManager: SOCKS ready at \(self.socksHost):\(self.socksPort)", category: .session)
} else {
self.lastError = NSError(domain: "TorManager", code: -13, userInfo: [NSLocalizedDescriptionKey: "Tor SOCKS not reachable after static start"])
SecureLogger.error("TorManager: SOCKS not reachable (timeout)", category: .session)
}
// isStarting will be cleared when bootstrap reaches 100%
}
}
return true
}
// MARK: - ControlPort monitoring (bootstrap progress)
private func startControlMonitorIfNeeded() {
guard !controlMonitorStarted else { return }
@@ -308,6 +440,10 @@ public final class TorManager: ObservableObject {
}
}
private func controlMonitorLoop() async {}
private func tryControlSessionOnce() async -> Bool { false }
// iOS: Poll GETINFO periodically to track bootstrap progress without long-lived control readers.
private func bootstrapPollLoop() async {
let deadline = Date().addingTimeInterval(75)