Compare commits

..
Author SHA1 Message Date
islam 436ab46aee Extract Noise into a dedicated module 2026-04-18 13:02:16 +01: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
127 changed files with 2207 additions and 457 deletions
+4
View File
@@ -19,6 +19,10 @@ jobs:
path: . path: .
- name: BitLogger - name: BitLogger
path: localPackages/BitLogger path: localPackages/BitLogger
- name: BitFoundation
path: localPackages/BitFoundation
- name: Noise
path: localPackages/Noise
steps: steps:
- name: Checkout code - name: Checkout code
+9 -3
View File
@@ -17,6 +17,8 @@ let package = Package(
], ],
dependencies:[ dependencies:[
.package(path: "localPackages/Arti"), .package(path: "localPackages/Arti"),
.package(path: "localPackages/Noise"),
.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,7 +27,9 @@ 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: "Noise", package: "Noise"),
.product(name: "Tor", package: "Arti") .product(name: "Tor", package: "Arti")
], ],
path: "bitchat", path: "bitchat",
@@ -44,15 +48,17 @@ 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")
] ]
) )
] ]
+40
View File
@@ -10,6 +10,10 @@
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 */; };
A63163B62F80CB2500B8B128 /* Noise in Frameworks */ = {isa = PBXBuildFile; productRef = A63163B52F80CB2500B8B128 /* Noise */; };
A63163B82F80CB2D00B8B128 /* Noise in Frameworks */ = {isa = PBXBuildFile; productRef = A63163B72F80CB2D00B8B128 /* Noise */; };
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 */; };
@@ -154,16 +158,20 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
files = ( files = (
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */, A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */,
A63163B82F80CB2D00B8B128 /* Noise 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 = (
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */, A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */,
A63163B62F80CB2500B8B128 /* Noise 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 */
@@ -223,6 +231,8 @@
B1D9136AA0083366353BFA2F /* P256K */, B1D9136AA0083366353BFA2F /* P256K */,
A6E3E5712E7703760032EA8A /* BitLogger */, A6E3E5712E7703760032EA8A /* BitLogger */,
A6E3EA802E7706A80032EA8A /* Tor */, A6E3EA802E7706A80032EA8A /* Tor */,
A6BCF9492F809550001CF9B9 /* BitFoundation */,
A63163B72F80CB2D00B8B128 /* Noise */,
); );
productName = bitchat_macOS; productName = bitchat_macOS;
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */; productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
@@ -303,6 +313,8 @@
4EB6BA1B8464F1EA38F4E286 /* P256K */, 4EB6BA1B8464F1EA38F4E286 /* P256K */,
A6E3E56F2E77036A0032EA8A /* BitLogger */, A6E3E56F2E77036A0032EA8A /* BitLogger */,
A6E3EA7E2E7706720032EA8A /* Tor */, A6E3EA7E2E7706720032EA8A /* Tor */,
A6BCF9472F80953E001CF9B9 /* BitFoundation */,
A63163B52F80CB2500B8B128 /* Noise */,
); );
productName = bitchat_iOS; productName = bitchat_iOS;
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */; productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
@@ -344,6 +356,8 @@
B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */, B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */,
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */, A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */, A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */,
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */,
A63163B42F80CB2500B8B128 /* XCLocalSwiftPackageReference "localPackages/Noise" */,
); );
preferredProjectObjectVersion = 90; preferredProjectObjectVersion = 90;
projectDirPath = ""; projectDirPath = "";
@@ -909,6 +923,14 @@
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */ /* Begin XCLocalSwiftPackageReference section */
A63163B42F80CB2500B8B128 /* XCLocalSwiftPackageReference "localPackages/Noise" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = localPackages/Noise;
};
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;
@@ -936,6 +958,24 @@
package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */; package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */;
productName = P256K; productName = P256K;
}; };
A63163B52F80CB2500B8B128 /* Noise */ = {
isa = XCSwiftPackageProductDependency;
productName = Noise;
};
A63163B72F80CB2D00B8B128 /* Noise */ = {
isa = XCSwiftPackageProductDependency;
package = A63163B42F80CB2500B8B128 /* XCLocalSwiftPackageReference "localPackages/Noise" */;
productName = Noise;
};
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;
+1
View File
@@ -8,6 +8,7 @@
import Tor import Tor
import SwiftUI import SwiftUI
import BitFoundation
import UserNotifications import UserNotifications
@main @main
+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
@@ -6,6 +6,7 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import BitFoundation
import Foundation import Foundation
extension BitchatMessage { extension BitchatMessage {
+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 {
+1
View File
@@ -7,6 +7,7 @@
// //
import Foundation import Foundation
import BitFoundation
struct ReadReceipt: Codable { struct ReadReceipt: Codable {
let originalMessageID: String let originalMessageID: String
+1
View File
@@ -1,4 +1,5 @@
import Foundation import Foundation
import BitFoundation
// MARK: - BitChat-over-Nostr Adapter // MARK: - BitChat-over-Nostr Adapter
+1
View File
@@ -1,3 +1,4 @@
import BitFoundation
import Foundation import Foundation
import CryptoKit import CryptoKit
@@ -7,6 +7,7 @@
// //
import Foundation import Foundation
import BitFoundation
import BitLogger import BitLogger
/// TLV payload for Bluetooth mesh file transfers (voice notes, images, generic files). /// TLV payload for Bluetooth mesh file transfers (voice notes, images, generic files).
+1 -63
View File
@@ -60,40 +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
case fileTransfer = 0x22 // Binary file/audio/image payloads
var description: String {
switch self {
case .announce: return "announce"
case .message: return "message"
case .leave: return "leave"
case .requestSync: return "requestSync"
case .noiseHandshake: return "noiseHandshake"
case .noiseEncrypted: return "noiseEncrypted"
case .fragment: return "fragment"
case .fileTransfer: return "fileTransfer"
}
}
}
// MARK: - Noise Payload Types // MARK: - Noise Payload Types
@@ -131,35 +98,6 @@ 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
protocol BitchatDelegate: AnyObject { protocol BitchatDelegate: AnyObject {
+1
View File
@@ -1,4 +1,5 @@
import BitLogger import BitLogger
import BitFoundation
import Foundation import Foundation
import CoreBluetooth import CoreBluetooth
import Combine import Combine
+1
View File
@@ -7,6 +7,7 @@
// //
import Foundation import Foundation
import BitFoundation
/// Result of command processing /// Result of command processing
enum CommandResult { enum CommandResult {
@@ -1,4 +1,5 @@
import BitLogger import BitLogger
import BitFoundation
import Foundation import Foundation
import Combine import Combine
+1 -67
View File
@@ -7,76 +7,10 @@
// //
import BitLogger import BitLogger
import BitFoundation
import Foundation import Foundation
import Security import Security
// MARK: - Keychain Error Types
// BCH-01-009: Proper error classification to distinguish expected states from critical failures
/// Result of a keychain read operation with proper error classification
enum KeychainReadResult {
case success(Data)
case itemNotFound // Expected: key doesn't exist yet
case accessDenied // Critical: app lacks keychain access
case deviceLocked // Recoverable: device is locked
case authenticationFailed // Recoverable: biometric/passcode failed
case otherError(OSStatus) // Unexpected error
var isRecoverableError: Bool {
switch self {
case .deviceLocked, .authenticationFailed:
return true
default:
return false
}
}
}
/// Result of a keychain save operation with proper error classification
enum KeychainSaveResult {
case success
case duplicateItem // Can retry with update
case accessDenied // Critical: app lacks keychain access
case deviceLocked // Recoverable: device is locked
case storageFull // Critical: no space available
case otherError(OSStatus)
var isRecoverableError: Bool {
switch self {
case .duplicateItem, .deviceLocked:
return true
default:
return false
}
}
}
protocol KeychainManagerProtocol {
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool
func getIdentityKey(forKey key: String) -> Data?
func deleteIdentityKey(forKey key: String) -> Bool
func deleteAllKeychainData() -> Bool
func secureClear(_ data: inout Data)
func secureClear(_ string: inout String)
func verifyIdentityKeyExists() -> Bool
// BCH-01-009: Methods with proper error classification
/// Get identity key with detailed result for error handling
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult
/// Save identity key with detailed result for error handling
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
/// Save data with a custom service name
func save(key: String, data: Data, service: String, accessible: CFString?)
/// Load data from a custom service
func load(key: String, service: String) -> Data?
/// Delete data from a custom service
func delete(key: String, service: String)
}
final class KeychainManager: KeychainManagerProtocol { final class KeychainManager: KeychainManagerProtocol {
// Use consistent service name for all keychain items // Use consistent service name for all keychain items
private let service = BitchatApp.bundleID private let service = BitchatApp.bundleID
@@ -6,6 +6,7 @@
// This is free and unencumbered software released into the public domain. // This is free and unencumbered software released into the public domain.
// //
import BitFoundation
import Foundation import Foundation
import SwiftUI import SwiftUI
+1
View File
@@ -1,4 +1,5 @@
import BitLogger import BitLogger
import BitFoundation
import Foundation import Foundation
/// Routes messages using available transports (Mesh, Nostr, etc.) /// Routes messages using available transports (Mesh, Nostr, etc.)
@@ -83,6 +83,8 @@
/// ///
import BitLogger import BitLogger
import BitFoundation
import Noise
import Foundation import Foundation
import CryptoKit import CryptoKit
+1
View File
@@ -1,4 +1,5 @@
import BitLogger import BitLogger
import BitFoundation
import Foundation import Foundation
import Combine import Combine
@@ -6,6 +6,7 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import BitFoundation
import Foundation import Foundation
import UserNotifications import UserNotifications
#if os(iOS) #if os(iOS)
@@ -7,6 +7,7 @@
// //
import BitLogger import BitLogger
import BitFoundation
import Foundation import Foundation
struct NotificationStreamAssembler { struct NotificationStreamAssembler {
@@ -7,6 +7,7 @@
// //
import BitLogger import BitLogger
import BitFoundation
import Foundation import Foundation
import SwiftUI import SwiftUI
+1
View File
@@ -1,3 +1,4 @@
import BitFoundation
import Foundation import Foundation
import Combine import Combine
-3
View File
@@ -133,9 +133,6 @@ enum TransportConfig {
static let nostrShortKeyDisplayLength: Int = 8 static let nostrShortKeyDisplayLength: Int = 8
static let nostrConvKeyPrefixLength: Int = 16 static let nostrConvKeyPrefixLength: Int = 16
// Compression
static let compressionThresholdBytes: Int = 100
// Message deduplication // Message deduplication
static let messageDedupMaxAgeSeconds: TimeInterval = 300 static let messageDedupMaxAgeSeconds: TimeInterval = 300
static let messageDedupMaxCount: Int = 1000 static let messageDedupMaxCount: Int = 1000
@@ -7,6 +7,7 @@
// //
import BitLogger import BitLogger
import BitFoundation
import Foundation import Foundation
import Combine import Combine
import SwiftUI import SwiftUI
+1
View File
@@ -1,5 +1,6 @@
import Foundation import Foundation
import BitLogger import BitLogger
import BitFoundation
// Gossip-based sync manager using on-demand GCS filters // Gossip-based sync manager using on-demand GCS filters
final class GossipSyncManager { final class GossipSyncManager {
+1
View File
@@ -1,3 +1,4 @@
import struct BitFoundation.BitchatPacket
import Foundation import Foundation
import CryptoKit import CryptoKit
+1
View File
@@ -8,6 +8,7 @@
import Foundation import Foundation
import BitLogger import BitLogger
import BitFoundation
/// Manages outgoing sync requests and validates incoming responses. /// Manages outgoing sync requests and validates incoming responses.
/// ///
+1
View File
@@ -1,3 +1,4 @@
import BitFoundation
import Foundation import Foundation
/// Bitfield describing which message types are covered by a REQUEST_SYNC round. /// Bitfield describing which message types are covered by a REQUEST_SYNC round.
-22
View File
@@ -1,22 +0,0 @@
//
// Data+SHA256.swift
// bitchat
//
// Created by Islam on 26/09/2025.
//
import struct Foundation.Data
import struct CryptoKit.SHA256
extension Data {
/// Returns the hex representation of SHA256 hash
func sha256Fingerprint() -> String {
// Implementation matches existing fingerprint generation in NoiseEncryptionService
sha256Hash().hexEncodedString()
}
/// Returns the SHA256 hash wrapped in Data
func sha256Hash() -> Data {
Data(SHA256.hash(data: self))
}
}
@@ -1,4 +1,5 @@
import Foundation import Foundation
import BitFoundation
/// Resolves a stable display name for peers, adding a short suffix when collisions exist. /// Resolves a stable display name for peers, adding a short suffix when collisions exist.
struct PeerDisplayNameResolver { struct PeerDisplayNameResolver {
+1
View File
@@ -78,6 +78,7 @@
/// ///
import BitLogger import BitLogger
import BitFoundation
import Foundation import Foundation
import SwiftUI import SwiftUI
import Combine import Combine
@@ -8,6 +8,7 @@
import Foundation import Foundation
import Combine import Combine
import BitLogger import BitLogger
import BitFoundation
import SwiftUI import SwiftUI
import Tor import Tor
@@ -8,6 +8,7 @@
import Foundation import Foundation
import Combine import Combine
import BitLogger import BitLogger
import BitFoundation
import SwiftUI import SwiftUI
extension ChatViewModel { extension ChatViewModel {
@@ -5,6 +5,7 @@
// Handles batching and deduplication of public chat messages before surfacing them to the UI. // Handles batching and deduplication of public chat messages before surfacing them to the UI.
// //
import BitFoundation
import Foundation import Foundation
@MainActor @MainActor
@@ -5,6 +5,7 @@
// Maintains mesh and geohash public timelines with simple caps and helpers. // Maintains mesh and geohash public timelines with simple caps and helpers.
// //
import BitFoundation
import Foundation import Foundation
struct PublicTimelineStore { struct PublicTimelineStore {
@@ -7,6 +7,7 @@
// //
import SwiftUI import SwiftUI
import BitFoundation
struct DeliveryStatusView: View { struct DeliveryStatusView: View {
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
@@ -7,6 +7,7 @@
// //
import SwiftUI import SwiftUI
import BitFoundation
struct TextMessageView: View { struct TextMessageView: View {
@Environment(\.colorScheme) private var colorScheme: ColorScheme @Environment(\.colorScheme) private var colorScheme: ColorScheme
+1
View File
@@ -15,6 +15,7 @@ import AppKit
#endif #endif
import UniformTypeIdentifiers import UniformTypeIdentifiers
import BitLogger import BitLogger
import BitFoundation
/// On macOS 14+, disables the default system focus ring on TextFields. /// On macOS 14+, disables the default system focus ring on TextFields.
/// On earlier macOS versions and on iOS this is a no-op. /// On earlier macOS versions and on iOS this is a no-op.
+1
View File
@@ -7,6 +7,7 @@
// //
import SwiftUI import SwiftUI
import BitFoundation
struct FingerprintView: View { struct FingerprintView: View {
@ObservedObject var viewModel: ChatViewModel @ObservedObject var viewModel: ChatViewModel
@@ -6,6 +6,7 @@
// //
import SwiftUI import SwiftUI
import BitFoundation
struct MediaMessageView: View { struct MediaMessageView: View {
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
+1
View File
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import BitFoundation
struct MeshPeerList: View { struct MeshPeerList: View {
@ObservedObject var viewModel: ChatViewModel @ObservedObject var viewModel: ChatViewModel
+1
View File
@@ -5,6 +5,7 @@
// Created by Islam on 30/03/2026. // Created by Islam on 30/03/2026.
// //
import BitFoundation
import SwiftUI import SwiftUI
private struct MessageDisplayItem: Identifiable { private struct MessageDisplayItem: Identifiable {
@@ -6,6 +6,7 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import BitFoundation
import Foundation import Foundation
extension BitchatMessage { extension BitchatMessage {
@@ -6,6 +6,7 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import BitFoundation
import Foundation import Foundation
final class PreviewKeychainManager: KeychainManagerProtocol { final class PreviewKeychainManager: KeychainManagerProtocol {
+1
View File
@@ -8,6 +8,7 @@
import Testing import Testing
import Foundation import Foundation
import CoreBluetooth import CoreBluetooth
import BitFoundation
@testable import bitchat @testable import bitchat
struct BLEServiceCoreTests { struct BLEServiceCoreTests {
+1
View File
@@ -8,6 +8,7 @@
import Testing import Testing
import CoreBluetooth import CoreBluetooth
@testable import BitFoundation // to avoid unnecessary public's
@testable import bitchat @testable import bitchat
struct BLEServiceTests { struct BLEServiceTests {
+1
View File
@@ -1,5 +1,6 @@
import Foundation import Foundation
import Testing import Testing
import BitFoundation
@testable import bitchat @testable import bitchat
@Suite("BitchatPeer Tests") @Suite("BitchatPeer Tests")
@@ -7,6 +7,7 @@
import Testing import Testing
import Foundation import Foundation
import BitFoundation
@testable import bitchat @testable import bitchat
// MARK: - Test Helpers // MARK: - Test Helpers
@@ -13,6 +13,7 @@ import UIKit
#else #else
import AppKit import AppKit
#endif #endif
import BitFoundation
@testable import bitchat @testable import bitchat
// MARK: - Test Helpers // MARK: - Test Helpers
@@ -8,6 +8,7 @@
import Testing import Testing
import Foundation import Foundation
import BitFoundation
@testable import bitchat @testable import bitchat
struct ChatViewModelRefactoringTests { struct ChatViewModelRefactoringTests {
+1
View File
@@ -8,6 +8,7 @@
import Testing import Testing
import Foundation import Foundation
import BitFoundation
@testable import bitchat @testable import bitchat
// MARK: - Test Helpers // MARK: - Test Helpers
+1
View File
@@ -1,5 +1,6 @@
import Foundation import Foundation
import Testing import Testing
import BitFoundation
@testable import bitchat @testable import bitchat
@Suite(.serialized) @Suite(.serialized)
@@ -9,6 +9,8 @@
import Testing import Testing
import CryptoKit import CryptoKit
import struct Foundation.UUID import struct Foundation.UUID
import Noise
@testable import BitFoundation // to avoid unnecessary public's
@testable import bitchat @testable import bitchat
struct PrivateChatE2ETests { struct PrivateChatE2ETests {
@@ -8,6 +8,7 @@
import Testing import Testing
import struct Foundation.UUID import struct Foundation.UUID
@testable import BitFoundation // to avoid unnecessary public's
@testable import bitchat @testable import bitchat
struct PublicChatE2ETests { struct PublicChatE2ETests {
@@ -9,6 +9,7 @@
import Testing import Testing
import Foundation import Foundation
import CoreBluetooth import CoreBluetooth
import BitFoundation
@testable import bitchat @testable import bitchat
struct FragmentationTests { struct FragmentationTests {
@@ -1,5 +1,6 @@
import Foundation import Foundation
import Testing import Testing
import BitFoundation
@testable import bitchat @testable import bitchat
struct GossipSyncManagerTests { struct GossipSyncManagerTests {
@@ -9,6 +9,8 @@
import Foundation import Foundation
import CryptoKit import CryptoKit
import Testing import Testing
import Noise
@testable import BitFoundation // to avoid unnecessary public's
@testable import bitchat @testable import bitchat
struct IntegrationTests { struct IntegrationTests {
@@ -8,6 +8,8 @@
import Foundation import Foundation
import CryptoKit import CryptoKit
import Noise
@testable import BitFoundation // to avoid unnecessary public's
@testable import bitchat @testable import bitchat
final class TestNetworkHelper { final class TestNetworkHelper {
@@ -9,6 +9,7 @@
import Testing import Testing
import Foundation import Foundation
import SwiftUI import SwiftUI
import BitFoundation
@testable import bitchat @testable import bitchat
struct MessageFormattingEngineTests { struct MessageFormattingEngineTests {
+1
View File
@@ -7,6 +7,7 @@
// //
import Foundation import Foundation
import BitFoundation
@testable import bitchat @testable import bitchat
final class MockBLEBus { final class MockBLEBus {
+1
View File
@@ -8,6 +8,7 @@
import Foundation import Foundation
import CoreBluetooth import CoreBluetooth
@testable import BitFoundation // to avoid unnecessary public's
@testable import bitchat @testable import bitchat
/// In-memory BLE test harness used by E2E/Integration tests. /// In-memory BLE test harness used by E2E/Integration tests.
@@ -7,6 +7,7 @@
// //
import Foundation import Foundation
import BitFoundation
@testable import bitchat @testable import bitchat
final class MockIdentityManager: SecureIdentityStateManagerProtocol { final class MockIdentityManager: SecureIdentityStateManagerProtocol {
+1
View File
@@ -7,6 +7,7 @@
// //
import Foundation import Foundation
import BitFoundation
@testable import bitchat @testable import bitchat
final class MockKeychain: KeychainManagerProtocol { final class MockKeychain: KeychainManagerProtocol {
+1
View File
@@ -9,6 +9,7 @@
import Foundation import Foundation
import Combine import Combine
import CoreBluetooth import CoreBluetooth
import BitFoundation
@testable import bitchat @testable import bitchat
/// Mock Transport implementation for testing ChatViewModel in isolation. /// Mock Transport implementation for testing ChatViewModel in isolation.
+78
View File
@@ -0,0 +1,78 @@
//
// NoiseEncryptionTests.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
@testable import bitchat
struct NoiseEncryptionTests {
@Test func generatesNewIdentityWhenMissing() throws {
let keychain = MockKeychain()
// Create service with empty keychain - should generate new identity
let service = NoiseEncryptionService(keychain: keychain)
// Should have generated and saved keys
#expect(service.getStaticPublicKeyData().count == 32)
#expect(service.getSigningPublicKeyData().count == 32)
// Keys should be persisted
let noiseKeyResult = keychain.getIdentityKeyWithResult(forKey: "noiseStaticKey")
switch noiseKeyResult {
case .success:
// Expected - key was saved
break
default:
throw KeychainTestError("Expected noise key to be saved")
}
}
@Test func loadsExistingIdentity() throws {
let keychain = MockKeychain()
// Create first service to generate identity
let service1 = NoiseEncryptionService(keychain: keychain)
let originalPublicKey = service1.getStaticPublicKeyData()
let originalSigningKey = service1.getSigningPublicKeyData()
// Create second service - should load same identity
let service2 = NoiseEncryptionService(keychain: keychain)
#expect(service2.getStaticPublicKeyData() == originalPublicKey)
#expect(service2.getSigningPublicKeyData() == originalSigningKey)
}
@Test func handlesAccessDeniedGracefully() throws {
let keychain = MockKeychain()
keychain.simulatedReadError = .accessDenied
// Service should still initialize with ephemeral key
let service = NoiseEncryptionService(keychain: keychain)
// Should have an identity (ephemeral)
#expect(service.getStaticPublicKeyData().count == 32)
#expect(service.getSigningPublicKeyData().count == 32)
}
@Test func handlesDeviceLockedGracefully() throws {
let keychain = MockKeychain()
keychain.simulatedReadError = .deviceLocked
// Service should still initialize with ephemeral key
let service = NoiseEncryptionService(keychain: keychain)
// Should have an identity (ephemeral)
#expect(service.getStaticPublicKeyData().count == 32)
}
}
// TODO: Reuse
private struct KeychainTestError: Error, CustomStringConvertible {
let message: String
init(_ message: String) { self.message = message }
var description: String { message }
}
+1
View File
@@ -8,6 +8,7 @@
import Testing import Testing
import CryptoKit import CryptoKit
import Foundation import Foundation
import BitFoundation
@testable import bitchat @testable import bitchat
struct NostrProtocolTests { struct NostrProtocolTests {
@@ -1,5 +1,6 @@
import Testing import Testing
import Foundation import Foundation
import BitFoundation
@testable import bitchat @testable import bitchat
struct NotificationStreamAssemblerTests { struct NotificationStreamAssemblerTests {
+1
View File
@@ -2,6 +2,7 @@ import Testing
import Foundation import Foundation
import Combine import Combine
import CoreBluetooth import CoreBluetooth
import BitFoundation
@testable import bitchat @testable import bitchat
private final class DefaultDelegateProbe: BitchatDelegate { private final class DefaultDelegateProbe: BitchatDelegate {
@@ -1,5 +1,6 @@
import Foundation import Foundation
import XCTest import XCTest
@testable import BitFoundation // to avoid unnecessary public's
@testable import bitchat @testable import bitchat
final class BinaryEncodingUtilsTests: XCTestCase { final class BinaryEncodingUtilsTests: XCTestCase {
@@ -7,6 +7,7 @@
import Testing import Testing
import Foundation import Foundation
import BitFoundation
@testable import bitchat @testable import bitchat
@MainActor @MainActor
+1
View File
@@ -1,5 +1,6 @@
import Foundation import Foundation
import Testing import Testing
import BitFoundation
@testable import bitchat @testable import bitchat
@Suite("ReadReceipt Tests") @Suite("ReadReceipt Tests")
@@ -1,4 +1,5 @@
import XCTest import XCTest
import BitFoundation
@testable import bitchat @testable import bitchat
@MainActor @MainActor
@@ -7,6 +7,7 @@
import Testing import Testing
import Foundation import Foundation
import BitFoundation
@testable import bitchat @testable import bitchat
struct MessageRouterTests { struct MessageRouterTests {
@@ -1,5 +1,6 @@
import Foundation import Foundation
import Testing import Testing
import BitFoundation
@testable import bitchat @testable import bitchat
@Suite("NoiseEncryptionService Tests") @Suite("NoiseEncryptionService Tests")
@@ -8,6 +8,7 @@
import Foundation import Foundation
import Testing import Testing
import BitFoundation
@testable import bitchat @testable import bitchat
@Suite("NostrTransport Tests") @Suite("NostrTransport Tests")
@@ -1,5 +1,6 @@
import XCTest import XCTest
import UserNotifications import UserNotifications
import BitFoundation
@testable import bitchat @testable import bitchat
final class NotificationServiceTests: XCTestCase { final class NotificationServiceTests: XCTestCase {
@@ -7,6 +7,7 @@
import Testing import Testing
import Foundation import Foundation
import BitFoundation
@testable import bitchat @testable import bitchat
struct PrivateChatManagerTests { struct PrivateChatManagerTests {
@@ -1,5 +1,6 @@
import Foundation import Foundation
import XCTest import XCTest
import BitFoundation
@testable import bitchat @testable import bitchat
final class SecureIdentityStateManagerTests: XCTestCase { final class SecureIdentityStateManagerTests: XCTestCase {
@@ -7,6 +7,7 @@
import Testing import Testing
import Foundation import Foundation
import BitFoundation
@testable import bitchat @testable import bitchat
struct UnifiedPeerServiceTests { struct UnifiedPeerServiceTests {
@@ -1,4 +1,5 @@
import XCTest import XCTest
import BitFoundation
@testable import bitchat @testable import bitchat
final class RequestSyncManagerTests: XCTestCase { final class RequestSyncManagerTests: XCTestCase {
@@ -8,6 +8,7 @@
import Foundation import Foundation
import CryptoKit import CryptoKit
import BitFoundation
@testable import bitchat @testable import bitchat
final class TestHelpers { final class TestHelpers {
+1
View File
@@ -8,6 +8,7 @@ import UIKit
#else #else
import AppKit import AppKit
#endif #endif
import BitFoundation
@testable import bitchat @testable import bitchat
@MainActor @MainActor
+33
View File
@@ -0,0 +1,33 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "BitFoundation",
platforms: [
.iOS(.v16),
.macOS(.v13)
],
products: [
.library(
name: "BitFoundation",
targets: ["BitFoundation"]
)
],
dependencies: [
.package(path: "../BitLogger")
],
targets: [
.target(
name: "BitFoundation",
dependencies: [
.product(name: "BitLogger", package: "BitLogger"),
],
path: "Sources"
),
.testTarget(
name: "BitFoundationTests",
dependencies: ["BitFoundation"],
)
]
)
@@ -5,70 +5,15 @@
// Binary encoding utilities for efficient protocol messages // Binary encoding utilities for efficient protocol messages
// //
import Foundation import struct Foundation.Data
import CryptoKit import struct Foundation.Date
// MARK: - Hex Encoding/Decoding
extension Data {
func hexEncodedString() -> String {
if self.isEmpty {
return ""
}
return self.map { String(format: "%02x", $0) }.joined()
}
func sha256Hex() -> String {
let digest = SHA256.hash(data: self)
return digest.map { String(format: "%02x", $0) }.joined()
}
/// Initialize Data from a hex string.
/// - Parameter hexString: A hex string, optionally prefixed with "0x" or "0X".
/// Whitespace is trimmed. Must have even length after prefix removal.
/// - Returns: nil if the string has odd length or contains invalid hex characters.
init?(hexString: String) {
var hex = hexString.trimmed
// Remove optional 0x prefix
if hex.hasPrefix("0x") || hex.hasPrefix("0X") {
hex = String(hex.dropFirst(2))
}
// Reject odd-length strings
guard hex.count % 2 == 0 else {
return nil
}
// Reject empty strings
guard !hex.isEmpty else {
self = Data()
return
}
let len = hex.count / 2
var data = Data(capacity: len)
var index = hex.startIndex
for _ in 0..<len {
let nextIndex = hex.index(index, offsetBy: 2)
guard let byte = UInt8(String(hex[index..<nextIndex]), radix: 16) else {
return nil
}
data.append(byte)
index = nextIndex
}
self = data
}
}
// MARK: - Binary Encoding Utilities // MARK: - Binary Encoding Utilities
extension Data { extension Data {
// MARK: Writing // MARK: Writing
@inlinable mutating func appendUInt8(_ value: UInt8) { @inlinable public mutating func appendUInt8(_ value: UInt8) {
self.append(value) self.append(value)
} }
@@ -90,7 +35,7 @@ extension Data {
} }
} }
mutating func appendString(_ string: String, maxLength: Int = 255) { public mutating func appendString(_ string: String, maxLength: Int = 255) {
guard let data = string.data(using: .utf8) else { return } guard let data = string.data(using: .utf8) else { return }
let length = Swift.min(data.count, maxLength) let length = Swift.min(data.count, maxLength)
@@ -103,7 +48,7 @@ extension Data {
self.append(data.prefix(length)) self.append(data.prefix(length))
} }
mutating func appendData(_ data: Data, maxLength: Int = 65535) { public mutating func appendData(_ data: Data, maxLength: Int = 65535) {
let length = Swift.min(data.count, maxLength) let length = Swift.min(data.count, maxLength)
if maxLength <= 255 { if maxLength <= 255 {
@@ -115,12 +60,12 @@ extension Data {
self.append(data.prefix(length)) self.append(data.prefix(length))
} }
mutating func appendDate(_ date: Date) { public mutating func appendDate(_ date: Date) {
let timestamp = UInt64(date.timeIntervalSince1970 * 1000) // milliseconds let timestamp = UInt64(date.timeIntervalSince1970 * 1000) // milliseconds
self.appendUInt64(timestamp) self.appendUInt64(timestamp)
} }
mutating func appendUUID(_ uuid: String) { public mutating func appendUUID(_ uuid: String) {
// Convert UUID string to 16 bytes // Convert UUID string to 16 bytes
var uuidData = Data(count: 16) var uuidData = Data(count: 16)
@@ -141,7 +86,7 @@ extension Data {
// MARK: Reading // MARK: Reading
@inlinable func readUInt8(at offset: inout Int) -> UInt8? { @inlinable public func readUInt8(at offset: inout Int) -> UInt8? {
guard offset >= 0 && offset < self.count else { return nil } guard offset >= 0 && offset < self.count else { return nil }
let value = self[offset] let value = self[offset]
offset += 1 offset += 1
@@ -175,7 +120,7 @@ extension Data {
return value return value
} }
func readString(at offset: inout Int, maxLength: Int = 255) -> String? { public func readString(at offset: inout Int, maxLength: Int = 255) -> String? {
let length: Int let length: Int
if maxLength <= 255 { if maxLength <= 255 {
@@ -194,7 +139,7 @@ extension Data {
return String(data: stringData, encoding: .utf8) return String(data: stringData, encoding: .utf8)
} }
func readData(at offset: inout Int, maxLength: Int = 65535) -> Data? { public func readData(at offset: inout Int, maxLength: Int = 65535) -> Data? {
let length: Int let length: Int
if maxLength <= 255 { if maxLength <= 255 {
@@ -213,12 +158,12 @@ extension Data {
return data return data
} }
func readDate(at offset: inout Int) -> Date? { public func readDate(at offset: inout Int) -> Date? {
guard let timestamp = readUInt64(at: &offset) else { return nil } guard let timestamp = readUInt64(at: &offset) else { return nil }
return Date(timeIntervalSince1970: Double(timestamp) / 1000.0) return Date(timeIntervalSince1970: Double(timestamp) / 1000.0)
} }
func readUUID(at offset: inout Int) -> String? { public func readUUID(at offset: inout Int) -> String? {
guard offset + 16 <= self.count else { return nil } guard offset + 16 <= self.count else { return nil }
let uuidData = self[offset..<offset + 16] let uuidData = self[offset..<offset + 16]
@@ -239,7 +184,7 @@ extension Data {
return result.uppercased() return result.uppercased()
} }
func readFixedBytes(at offset: inout Int, count: Int) -> Data? { public func readFixedBytes(at offset: inout Int, count: Int) -> Data? {
guard offset + count <= self.count else { return nil } guard offset + count <= self.count else { return nil }
let data = self[offset..<offset + count] let data = self[offset..<offset + count]
@@ -88,40 +88,31 @@
/// - Platform-optimized byte swapping /// - Platform-optimized byte swapping
/// ///
import Foundation import struct Foundation.Data
import BitLogger import class Foundation.NSData
private import BitLogger
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. /// Implements binary encoding and decoding for BitChat protocol messages.
/// Provides static methods for converting between BitchatPacket objects and /// Provides static methods for converting between BitchatPacket objects and
/// their binary wire format representation. /// their binary wire format representation.
/// - Note: All multi-byte values use network byte order (big-endian) /// - Note: All multi-byte values use network byte order (big-endian)
struct BinaryProtocol { public struct BinaryProtocol {
static let v1HeaderSize = 14 public static let v1HeaderSize = 14
static let v2HeaderSize = 16 static let v2HeaderSize = 16
static let senderIDSize = 8 public static let senderIDSize = 8
static let recipientIDSize = 8 public static let recipientIDSize = 8
static let signatureSize = 64 public static let signatureSize = 64
// Field offsets within packet header // Field offsets within packet header
struct Offsets { public struct Offsets {
static let version = 0 static let version = 0
static let type = 1 static let type = 1
static let ttl = 2 static let ttl = 2
static let timestamp = 3 static let timestamp = 3
static let flags = 11 // After version(1) + type(1) + ttl(1) + timestamp(8) public static let flags = 11 // After version(1) + type(1) + ttl(1) + timestamp(8)
} }
static func headerSize(for version: UInt8) -> Int? { public static func headerSize(for version: UInt8) -> Int? {
switch version { switch version {
case 1: return v1HeaderSize case 1: return v1HeaderSize
case 2: return v2HeaderSize case 2: return v2HeaderSize
@@ -133,11 +124,11 @@ struct BinaryProtocol {
return version == 2 ? 4 : 2 return version == 2 ? 4 : 2
} }
struct Flags { public struct Flags {
static let hasRecipient: UInt8 = 0x01 public static let hasRecipient: UInt8 = 0x01
static let hasSignature: UInt8 = 0x02 public static let hasSignature: UInt8 = 0x02
static let isCompressed: UInt8 = 0x04 public static let isCompressed: UInt8 = 0x04
static let hasRoute: UInt8 = 0x08 public static let hasRoute: UInt8 = 0x08
static let isRSR: UInt8 = 0x10 static let isRSR: UInt8 = 0x10
} }
@@ -266,7 +257,7 @@ struct BinaryProtocol {
} }
// Decode binary data to BitchatPacket // Decode binary data to BitchatPacket
static func decode(_ data: Data) -> BitchatPacket? { public static func decode(_ data: Data) -> BitchatPacket? {
// Try decode as-is first (robust when padding wasn't applied) // Try decode as-is first (robust when padding wasn't applied)
if let pkt = decodeCore(data) { return pkt } if let pkt = decodeCore(data) { return pkt }
// If that fails, try after removing padding // If that fails, try after removing padding
@@ -6,33 +6,39 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import Foundation import class Foundation.DateFormatter
import struct Foundation.AttributedString
import struct Foundation.Data
import struct Foundation.Date
import struct Foundation.TimeInterval
import struct Foundation.UUID
/// Represents a user-visible message in the BitChat system. /// Represents a user-visible message in the BitChat system.
/// Handles both broadcast messages and private encrypted messages, /// Handles both broadcast messages and private encrypted messages,
/// with support for mentions, replies, and delivery tracking. /// with support for mentions, replies, and delivery tracking.
/// - Note: This is the primary data model for chat messages /// - Note: This is the primary data model for chat messages
final class BitchatMessage: Codable { public final class BitchatMessage: Codable {
let id: String public let id: String
let sender: String public let sender: String
let content: String public let content: String
let timestamp: Date public let timestamp: Date
let isRelay: Bool public let isRelay: Bool
let originalSender: String? public let originalSender: String?
let isPrivate: Bool public let isPrivate: Bool
let recipientNickname: String? public let recipientNickname: String?
let senderPeerID: PeerID? public let senderPeerID: PeerID?
let mentions: [String]? // Array of mentioned nicknames public let mentions: [String]? // Array of mentioned nicknames
var deliveryStatus: DeliveryStatus? // Delivery tracking public var deliveryStatus: DeliveryStatus? // Delivery tracking
// Cached formatted text (not included in Codable) // Cached formatted text (not included in Codable)
private var _cachedFormattedText: [String: AttributedString] = [:] private var _cachedFormattedText: [String: AttributedString] = [:]
func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? { public func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? {
return _cachedFormattedText["\(isDark)-\(isSelf)"] return _cachedFormattedText["\(isDark)-\(isSelf)"]
} }
func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool) { public func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool) {
_cachedFormattedText["\(isDark)-\(isSelf)"] = text _cachedFormattedText["\(isDark)-\(isSelf)"] = text
} }
@@ -42,7 +48,7 @@ final class BitchatMessage: Codable {
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus
} }
init( public init(
id: String? = nil, id: String? = nil,
sender: String, sender: String,
content: String, content: String,
@@ -72,7 +78,7 @@ final class BitchatMessage: Codable {
// MARK: - Equatable Conformance // MARK: - Equatable Conformance
extension BitchatMessage: Equatable { extension BitchatMessage: Equatable {
static func == (lhs: BitchatMessage, rhs: BitchatMessage) -> Bool { public static func == (lhs: BitchatMessage, rhs: BitchatMessage) -> Bool {
return lhs.id == rhs.id && return lhs.id == rhs.id &&
lhs.sender == rhs.sender && lhs.sender == rhs.sender &&
lhs.content == rhs.content && lhs.content == rhs.content &&
@@ -330,14 +336,14 @@ extension BitchatMessage {
return formatter return formatter
}() }()
var formattedTimestamp: String { public var formattedTimestamp: String {
Self.timestampFormatter.string(from: timestamp) Self.timestampFormatter.string(from: timestamp)
} }
} }
extension Array where Element == BitchatMessage { extension Array where Element == BitchatMessage {
/// Filters out empty ones and deduplicate by ID while preserving order (from oldest to newest) /// Filters out empty ones and deduplicate by ID while preserving order (from oldest to newest)
func cleanedAndDeduped() -> [Element] { public func cleanedAndDeduped() -> [Element] {
let arr = filter { $0.content.trimmed.isEmpty == false } let arr = filter { $0.content.trimmed.isEmpty == false }
guard arr.count > 1 else { guard arr.count > 1 else {
return arr return arr
@@ -6,25 +6,26 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import Foundation import struct Foundation.Data
import struct Foundation.Date
/// The core packet structure for all BitChat protocol messages. /// The core packet structure for all BitChat protocol messages.
/// Encapsulates all data needed for routing through the mesh network, /// Encapsulates all data needed for routing through the mesh network,
/// including TTL for hop limiting and optional encryption. /// including TTL for hop limiting and optional encryption.
/// - Note: Packets larger than BLE MTU (512 bytes) are automatically fragmented /// - Note: Packets larger than BLE MTU (512 bytes) are automatically fragmented
struct BitchatPacket: Codable { public struct BitchatPacket: Codable {
let version: UInt8 let version: UInt8
let type: UInt8 public let type: UInt8
let senderID: Data public let senderID: Data
let recipientID: Data? public let recipientID: Data?
let timestamp: UInt64 public let timestamp: UInt64
let payload: Data public let payload: Data
var signature: Data? public var signature: Data?
var ttl: UInt8 public var ttl: UInt8
var route: [Data]? public var route: [Data]?
var isRSR: Bool public var isRSR: Bool
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil, isRSR: Bool = false) { public init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil, isRSR: Bool = false) {
self.version = version self.version = version
self.type = type self.type = type
self.senderID = senderID self.senderID = senderID
@@ -65,18 +66,18 @@ struct BitchatPacket: Codable {
BinaryProtocol.encode(self) BinaryProtocol.encode(self)
} }
func toBinaryData(padding: Bool = true) -> Data? { public func toBinaryData(padding: Bool = true) -> Data? {
BinaryProtocol.encode(self, padding: padding) BinaryProtocol.encode(self, padding: padding)
} }
// Backward-compatible helper (defaults to padded encoding) // Backward-compatible helper (defaults to padded encoding)
func toBinaryData() -> Data? { public func toBinaryData() -> Data? {
toBinaryData(padding: true) toBinaryData(padding: true)
} }
/// Create binary representation for signing (without signature and TTL fields) /// Create binary representation for signing (without signature and TTL fields)
/// TTL is excluded because it changes during packet relay operations /// TTL is excluded because it changes during packet relay operations
func toBinaryDataForSigning() -> Data? { public func toBinaryDataForSigning() -> Data? {
// Create a copy without signature and with fixed TTL for signing // Create a copy without signature and with fixed TTL for signing
// TTL must be excluded because it changes during relay // TTL must be excluded because it changes during relay
let unsignedPacket = BitchatPacket( let unsignedPacket = BitchatPacket(
@@ -94,7 +95,7 @@ struct BitchatPacket: Codable {
return BinaryProtocol.encode(unsignedPacket) return BinaryProtocol.encode(unsignedPacket)
} }
static func from(_ data: Data) -> BitchatPacket? { public static func from(_ data: Data) -> BitchatPacket? {
BinaryProtocol.decode(data) BinaryProtocol.decode(data)
} }
} }
@@ -6,12 +6,12 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import Foundation import struct Foundation.Data
import Compression private import Compression
struct CompressionUtil { struct CompressionUtil {
// Compression threshold - don't compress if data is smaller than this // Compression threshold - don't compress if data is smaller than this
static let compressionThreshold = TransportConfig.compressionThresholdBytes // bytes static let compressionThreshold = Constants.compressionThresholdBytes // bytes
// Compress data using zlib algorithm (most compatible) // Compress data using zlib algorithm (most compatible)
static func compress(_ data: Data) -> Data? { static func compress(_ data: Data) -> Data? {
@@ -0,0 +1,12 @@
//
// Constants.swift
// BitFoundation
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
enum Constants {
// Compression
static let compressionThresholdBytes: Int = 100
}
@@ -0,0 +1,57 @@
//
// Data+Hex.swift
// BitFoundation
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import struct Foundation.Data
public extension Data {
func hexEncodedString() -> String {
if self.isEmpty {
return ""
}
return self.map { String(format: "%02x", $0) }.joined()
}
/// Initialize Data from a hex string.
/// - Parameter hexString: A hex string, optionally prefixed with "0x" or "0X".
/// Whitespace is trimmed. Must have even length after prefix removal.
/// - Returns: nil if the string has odd length or contains invalid hex characters.
init?(hexString: String) {
var hex = hexString.trimmed
// Remove optional 0x prefix
if hex.hasPrefix("0x") || hex.hasPrefix("0X") {
hex = String(hex.dropFirst(2))
}
// Reject odd-length strings
guard hex.count % 2 == 0 else {
return nil
}
// Reject empty strings
guard !hex.isEmpty else {
self = Data()
return
}
let len = hex.count / 2
var data = Data(capacity: len)
var index = hex.startIndex
for _ in 0..<len {
let nextIndex = hex.index(index, offsetBy: 2)
guard let byte = UInt8(String(hex[index..<nextIndex]), radix: 16) else {
return nil
}
data.append(byte)
index = nextIndex
}
self = data
}
}
@@ -0,0 +1,33 @@
//
// Data+SHA256.swift
// BitFoundation
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import struct Foundation.Data
private import struct CryptoKit.SHA256
public extension Data {
/// Returns the hex representation of SHA256 hash
func sha256Fingerprint() -> String {
// Implementation matches existing fingerprint generation in NoiseEncryptionService
sha256Hash().hexEncodedString()
}
/// Returns the SHA256 hash wrapped in Data
func sha256Hash() -> Data {
Data(sha256Digest)
}
func sha256Hex() -> String {
sha256Digest.map { String(format: "%02x", $0) }.joined()
}
}
private extension Data {
var sha256Digest: SHA256.Digest {
SHA256.hash(data: self)
}
}
@@ -0,0 +1,35 @@
//
// DeliveryStatus.swift
// BitFoundation
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import struct Foundation.Date
public 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
public 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)"
}
}
}
@@ -1,15 +1,13 @@
import Foundation
/// Centralized thresholds for Bluetooth file transfers to keep payload sizes sane on constrained radios. /// Centralized thresholds for Bluetooth file transfers to keep payload sizes sane on constrained radios.
enum FileTransferLimits { public enum FileTransferLimits {
/// Absolute ceiling enforced for any file payload (voice, image, other). /// Absolute ceiling enforced for any file payload (voice, image, other).
static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB public static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB
/// Voice notes stay small for low-latency relays. /// Voice notes stay small for low-latency relays.
static let maxVoiceNoteBytes: Int = 512 * 1024 // 512 KiB public static let maxVoiceNoteBytes: Int = 512 * 1024 // 512 KiB
/// Compressed images after downscaling should comfortably fit under this budget. /// Compressed images after downscaling should comfortably fit under this budget.
static let maxImageBytes: Int = 512 * 1024 // 512 KiB public static let maxImageBytes: Int = 512 * 1024 // 512 KiB
/// Worst-case size once TLV metadata and binary packet framing are included for the largest payloads. /// Worst-case size once TLV metadata and binary packet framing are included for the largest payloads.
static let maxFramedFileBytes: Int = { public static let maxFramedFileBytes: Int = {
let maxMetadataBytes = Int(UInt16.max) * 2 // fileName + mimeType TLVs let maxMetadataBytes = Int(UInt16.max) * 2 // fileName + mimeType TLVs
let tlvEnvelopeOverhead = 18 + maxMetadataBytes // TLV tags + lengths + metadata bytes let tlvEnvelopeOverhead = 18 + maxMetadataBytes // TLV tags + lengths + metadata bytes
let binaryEnvelopeOverhead = BinaryProtocol.v2HeaderSize let binaryEnvelopeOverhead = BinaryProtocol.v2HeaderSize
@@ -19,7 +17,7 @@ enum FileTransferLimits {
return maxPayloadBytes + tlvEnvelopeOverhead + binaryEnvelopeOverhead return maxPayloadBytes + tlvEnvelopeOverhead + binaryEnvelopeOverhead
}() }()
static func isValidPayload(_ size: Int) -> Bool { public static func isValidPayload(_ size: Int) -> Bool {
size <= maxPayloadBytes size <= maxPayloadBytes
} }
} }
@@ -0,0 +1,78 @@
//
// KeychainManagerProtocol.swift
// BitFoundation
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import struct Foundation.Data
import class CoreFoundation.CFString
import typealias Darwin.OSStatus
public protocol KeychainManagerProtocol {
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool
func getIdentityKey(forKey key: String) -> Data?
func deleteIdentityKey(forKey key: String) -> Bool
func deleteAllKeychainData() -> Bool
func secureClear(_ data: inout Data)
func secureClear(_ string: inout String)
func verifyIdentityKeyExists() -> Bool
// BCH-01-009: Methods with proper error classification
/// Get identity key with detailed result for error handling
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult
/// Save identity key with detailed result for error handling
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
/// Save data with a custom service name
func save(key: String, data: Data, service: String, accessible: CFString?)
/// Load data from a custom service
func load(key: String, service: String) -> Data?
/// Delete data from a custom service
func delete(key: String, service: String)
}
// MARK: - Keychain Error Types
// BCH-01-009: Proper error classification to distinguish expected states from critical failures
/// Result of a keychain read operation with proper error classification
public enum KeychainReadResult {
case success(Data)
case itemNotFound // Expected: key doesn't exist yet
case accessDenied // Critical: app lacks keychain access
case deviceLocked // Recoverable: device is locked
case authenticationFailed // Recoverable: biometric/passcode failed
case otherError(OSStatus) // Unexpected error
public var isRecoverableError: Bool {
switch self {
case .deviceLocked, .authenticationFailed:
return true
default:
return false
}
}
}
/// Result of a keychain save operation with proper error classification
public enum KeychainSaveResult {
case success
case duplicateItem // Can retry with update
case accessDenied // Critical: app lacks keychain access
case deviceLocked // Recoverable: device is locked
case storageFull // Critical: no space available
case otherError(OSStatus)
public var isRecoverableError: Bool {
switch self {
case .duplicateItem, .deviceLocked:
return true
default:
return false
}
}
}
@@ -6,7 +6,7 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import Foundation import struct Foundation.Data
/// Provides privacy-preserving message padding to obscure actual content length. /// Provides privacy-preserving message padding to obscure actual content length.
/// Uses PKCS#7-style padding with random bytes to prevent traffic analysis. /// Uses PKCS#7-style padding with random bytes to prevent traffic analysis.
@@ -0,0 +1,39 @@
//
// MessageType.swift
// BitFoundation
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
/// 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.
public 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
case fileTransfer = 0x22 // Binary file/audio/image payloads
public var description: String {
switch self {
case .announce: return "announce"
case .message: return "message"
case .leave: return "leave"
case .requestSync: return "requestSync"
case .noiseHandshake: return "noiseHandshake"
case .noiseEncrypted: return "noiseEncrypted"
case .fragment: return "fragment"
case .fileTransfer: return "fileTransfer"
}
}
}
@@ -1,15 +1,27 @@
// //
// PeerID.swift // PeerID.swift
// bitchat // BitFoundation
// //
// This is free and unencumbered software released into the public domain. // This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import Foundation import struct Foundation.Data
import struct Foundation.CharacterSet
struct PeerID: Equatable, Hashable { public struct PeerID: Equatable, Hashable, Sendable {
enum Prefix: String, CaseIterable { enum Constants {
/// 16
static let nostrConvKeyPrefixLength = 16
/// 8
static let nostrShortKeyDisplayLength = 8
/// 64
fileprivate static let maxIDLength = 64
/// 16
fileprivate static let hexIDLength = 16 // 8 bytes = 16 hex chars
}
public enum Prefix: String, CaseIterable, Sendable {
/// When no prefix is provided /// When no prefix is provided
case empty = "" case empty = ""
/// `"mesh:"` /// `"mesh:"`
@@ -24,13 +36,13 @@ struct PeerID: Equatable, Hashable {
case geoChat = "nostr:" case geoChat = "nostr:"
} }
let prefix: Prefix public let prefix: Prefix
/// Returns the actual value without any prefix /// Returns the actual value without any prefix
let bare: String public let bare: String
/// Returns the full `id` value by combining `(prefix + bare)` /// Returns the full `id` value by combining `(prefix + bare)`
var id: String { prefix.rawValue + bare } public var id: String { prefix.rawValue + bare }
// Private so the callers have to go through a convenience init // Private so the callers have to go through a convenience init
private init(prefix: Prefix, bare: any StringProtocol) { private init(prefix: Prefix, bare: any StringProtocol) {
@@ -41,15 +53,15 @@ struct PeerID: Equatable, Hashable {
// MARK: - Convenience Inits // MARK: - Convenience Inits
extension PeerID { public extension PeerID {
/// Convenience init to create GeoDM PeerID by appending `"nostr_"` to the first 16 characters of `pubKey` /// Convenience init to create GeoDM PeerID by appending `"nostr_"` to the first 16 characters of `pubKey`
init(nostr_ pubKey: String) { init(nostr_ pubKey: String) {
self.init(prefix: .geoDM, bare: pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength)) self.init(prefix: .geoDM, bare: pubKey.prefix(Constants.nostrConvKeyPrefixLength))
} }
/// Convenience init to create GeoChat PeerID by appending `"nostr:"` to the first 8 characters of `pubKey` /// Convenience init to create GeoChat PeerID by appending `"nostr:"` to the first 8 characters of `pubKey`
init(nostr pubKey: String) { init(nostr pubKey: String) {
self.init(prefix: .geoChat, bare: pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength)) self.init(prefix: .geoChat, bare: pubKey.prefix(Constants.nostrShortKeyDisplayLength))
} }
/// Convenience init to create PeerID from String/Substring by splitting it into prefix and bare parts /// Convenience init to create PeerID from String/Substring by splitting it into prefix and bare parts
@@ -86,7 +98,7 @@ extension PeerID {
// MARK: - Noise Public Key Helpers // MARK: - Noise Public Key Helpers
extension PeerID { public extension PeerID {
/// Derive the stable 16-hex peer ID from a Noise static public key /// Derive the stable 16-hex peer ID from a Noise static public key
init(publicKey: Data) { init(publicKey: Data) {
self.init(str: publicKey.sha256Fingerprint().prefix(16)) self.init(str: publicKey.sha256Fingerprint().prefix(16))
@@ -104,11 +116,11 @@ extension PeerID {
// MARK: - Codable // MARK: - Codable
extension PeerID: Codable { extension PeerID: Codable {
init(from decoder: any Decoder) throws { public init(from decoder: any Decoder) throws {
self.init(str: try decoder.singleValueContainer().decode(String.self)) self.init(str: try decoder.singleValueContainer().decode(String.self))
} }
func encode(to encoder: any Encoder) throws { public func encode(to encoder: any Encoder) throws {
var container = encoder.singleValueContainer() var container = encoder.singleValueContainer()
try container.encode(id) try container.encode(id)
} }
@@ -116,7 +128,7 @@ extension PeerID: Codable {
// MARK: - Helpers // MARK: - Helpers
extension PeerID { public extension PeerID {
var isEmpty: Bool { var isEmpty: Bool {
id.isEmpty id.isEmpty
} }
@@ -136,7 +148,7 @@ extension PeerID {
} }
} }
extension PeerID { public extension PeerID {
var routingData: Data? { var routingData: Data? {
if let direct = Data(hexString: id), direct.count == 8 { return direct } if let direct = Data(hexString: id), direct.count == 8 { return direct }
if let bareData = Data(hexString: bare), bareData.count == 8 { return bareData } if let bareData = Data(hexString: bare), bareData.count == 8 { return bareData }
@@ -152,12 +164,7 @@ extension PeerID {
// MARK: - Validation // MARK: - Validation
extension PeerID { public extension PeerID {
private enum Constants {
static let maxIDLength = 64
static let hexIDLength = 16 // 8 bytes = 16 hex chars
}
/// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64) /// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64)
var isValid: Bool { var isValid: Bool {
if prefix != .empty { if prefix != .empty {
@@ -206,7 +213,7 @@ extension PeerID {
// MARK: - Comparable // MARK: - Comparable
extension PeerID: Comparable { extension PeerID: Comparable {
static func < (lhs: PeerID, rhs: PeerID) -> Bool { public static func < (lhs: PeerID, rhs: PeerID) -> Bool {
lhs.id < rhs.id lhs.id < rhs.id
} }
} }
@@ -215,7 +222,7 @@ extension PeerID: Comparable {
extension PeerID: CustomStringConvertible { extension PeerID: CustomStringConvertible {
/// So it returns the actual `id` like before even inside another String /// So it returns the actual `id` like before even inside another String
var description: String { public var description: String {
id id
} }
} }

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