From 436ab46aee5745f15e44bf398428b8a0fd23ff6e Mon Sep 17 00:00:00 2001 From: islam <2553451+qalandarov@users.noreply.github.com> Date: Sat, 4 Apr 2026 00:32:05 +0100 Subject: [PATCH] Extract Noise into a dedicated module --- .github/workflows/swift-tests.yml | 2 + Package.swift | 5 +- bitchat.xcodeproj/project.pbxproj | 20 + bitchat/Services/NoiseEncryptionService.swift | 1 + .../EndToEnd/PrivateChatE2ETests.swift | 1 + .../Integration/IntegrationTests.swift | 1 + .../Integration/TestNetworkHelper.swift | 1 + localPackages/Noise/Package.swift | 38 + .../Noise/NoiseEncryptionService.swift | 772 ++++++++++++++++++ .../Noise/Sources}/Noise/NoiseProtocol.swift | 2 +- .../Sources}/Noise/NoiseRateLimiter.swift | 12 +- .../Noise/NoiseSecurityConstants.swift | 10 +- .../Sources}/Noise/NoiseSecurityError.swift | 2 +- .../Noise/NoiseSecurityValidator.swift | 6 +- .../Noise/Sources}/Noise/NoiseSession.swift | 8 +- .../Sources}/Noise/NoiseSessionError.swift | 2 +- .../Sources}/Noise/NoiseSessionManager.swift | 26 +- .../Sources}/Noise/NoiseSessionState.swift | 0 .../Sources}/Noise/SecureNoiseSession.swift | 0 .../Noise/Tests/NoiseTests/MockKeychain.swift | 201 +++++ .../NoiseTests}/NoiseCoverageTests.swift | 3 +- .../NoiseTests/NoiseEncryptionTests.swift | 80 ++ .../NoiseTests}/NoiseProtocolTests.swift | 6 +- .../NoiseTests}/NoiseRateLimiterTests.swift | 2 +- .../Tests/NoiseTests}/NoiseTestVectors.json | 0 .../Noise/Tests/NoiseTests/TestHelpers.swift | 42 + 26 files changed, 1201 insertions(+), 42 deletions(-) create mode 100644 localPackages/Noise/Package.swift create mode 100644 localPackages/Noise/Sources/Noise/NoiseEncryptionService.swift rename {bitchat => localPackages/Noise/Sources}/Noise/NoiseProtocol.swift (99%) rename {bitchat => localPackages/Noise/Sources}/Noise/NoiseRateLimiter.swift (94%) rename {bitchat => localPackages/Noise/Sources}/Noise/NoiseSecurityConstants.swift (80%) rename {bitchat => localPackages/Noise/Sources}/Noise/NoiseSecurityError.swift (89%) rename {bitchat => localPackages/Noise/Sources}/Noise/NoiseSecurityValidator.swift (70%) rename {bitchat => localPackages/Noise/Sources}/Noise/NoiseSession.swift (98%) rename {bitchat => localPackages/Noise/Sources}/Noise/NoiseSessionError.swift (84%) rename {bitchat => localPackages/Noise/Sources}/Noise/NoiseSessionManager.swift (88%) rename {bitchat => localPackages/Noise/Sources}/Noise/NoiseSessionState.swift (100%) rename {bitchat => localPackages/Noise/Sources}/Noise/SecureNoiseSession.swift (100%) create mode 100644 localPackages/Noise/Tests/NoiseTests/MockKeychain.swift rename {bitchatTests/Noise => localPackages/Noise/Tests/NoiseTests}/NoiseCoverageTests.swift (99%) create mode 100644 localPackages/Noise/Tests/NoiseTests/NoiseEncryptionTests.swift rename {bitchatTests/Noise => localPackages/Noise/Tests/NoiseTests}/NoiseProtocolTests.swift (99%) rename {bitchatTests/Noise => localPackages/Noise/Tests/NoiseTests}/NoiseRateLimiterTests.swift (98%) rename {bitchatTests/Noise => localPackages/Noise/Tests/NoiseTests}/NoiseTestVectors.json (100%) create mode 100644 localPackages/Noise/Tests/NoiseTests/TestHelpers.swift diff --git a/.github/workflows/swift-tests.yml b/.github/workflows/swift-tests.yml index a56b9030..54c121e5 100644 --- a/.github/workflows/swift-tests.yml +++ b/.github/workflows/swift-tests.yml @@ -21,6 +21,8 @@ jobs: path: localPackages/BitLogger - name: BitFoundation path: localPackages/BitFoundation + - name: Noise + path: localPackages/Noise steps: - name: Checkout code diff --git a/Package.swift b/Package.swift index d7bc2cb6..9eb6a935 100644 --- a/Package.swift +++ b/Package.swift @@ -17,6 +17,7 @@ let package = Package( ], dependencies:[ .package(path: "localPackages/Arti"), + .package(path: "localPackages/Noise"), .package(path: "localPackages/BitFoundation"), .package(path: "localPackages/BitLogger"), .package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1") @@ -28,6 +29,7 @@ let package = Package( .product(name: "P256K", package: "swift-secp256k1"), .product(name: "BitFoundation", package: "BitFoundation"), .product(name: "BitLogger", package: "BitLogger"), + .product(name: "Noise", package: "Noise"), .product(name: "Tor", package: "Arti") ], path: "bitchat", @@ -56,8 +58,7 @@ let package = Package( "README.md" ], resources: [ - .process("Localization"), - .process("Noise") + .process("Localization") ] ) ] diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index a648a09f..6f04ce99 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -10,6 +10,8 @@ 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 */; }; 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 */; }; @@ -156,6 +158,7 @@ isa = PBXFrameworksBuildPhase; files = ( A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */, + A63163B82F80CB2D00B8B128 /* Noise in Frameworks */, 3EE336D150427F736F32B56C /* P256K in Frameworks */, A6E3EA812E7706A80032EA8A /* Tor in Frameworks */, A6BCF94A2F809550001CF9B9 /* BitFoundation in Frameworks */, @@ -165,6 +168,7 @@ isa = PBXFrameworksBuildPhase; files = ( A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */, + A63163B62F80CB2500B8B128 /* Noise in Frameworks */, 885BBED78092484A5B069461 /* P256K in Frameworks */, A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */, A6BCF9482F80953E001CF9B9 /* BitFoundation in Frameworks */, @@ -228,6 +232,7 @@ A6E3E5712E7703760032EA8A /* BitLogger */, A6E3EA802E7706A80032EA8A /* Tor */, A6BCF9492F809550001CF9B9 /* BitFoundation */, + A63163B72F80CB2D00B8B128 /* Noise */, ); productName = bitchat_macOS; productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */; @@ -309,6 +314,7 @@ A6E3E56F2E77036A0032EA8A /* BitLogger */, A6E3EA7E2E7706720032EA8A /* Tor */, A6BCF9472F80953E001CF9B9 /* BitFoundation */, + A63163B52F80CB2500B8B128 /* Noise */, ); productName = bitchat_iOS; productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */; @@ -351,6 +357,7 @@ A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */, A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */, A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */, + A63163B42F80CB2500B8B128 /* XCLocalSwiftPackageReference "localPackages/Noise" */, ); preferredProjectObjectVersion = 90; projectDirPath = ""; @@ -916,6 +923,10 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ + A63163B42F80CB2500B8B128 /* XCLocalSwiftPackageReference "localPackages/Noise" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = localPackages/Noise; + }; A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */ = { isa = XCLocalSwiftPackageReference; relativePath = localPackages/BitFoundation; @@ -947,6 +958,15 @@ package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */; 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; diff --git a/bitchat/Services/NoiseEncryptionService.swift b/bitchat/Services/NoiseEncryptionService.swift index 70e43c0d..740f8206 100644 --- a/bitchat/Services/NoiseEncryptionService.swift +++ b/bitchat/Services/NoiseEncryptionService.swift @@ -84,6 +84,7 @@ import BitLogger import BitFoundation +import Noise import Foundation import CryptoKit diff --git a/bitchatTests/EndToEnd/PrivateChatE2ETests.swift b/bitchatTests/EndToEnd/PrivateChatE2ETests.swift index dc8de7ab..8ac0616a 100644 --- a/bitchatTests/EndToEnd/PrivateChatE2ETests.swift +++ b/bitchatTests/EndToEnd/PrivateChatE2ETests.swift @@ -9,6 +9,7 @@ import Testing import CryptoKit import struct Foundation.UUID +import Noise @testable import BitFoundation // to avoid unnecessary public's @testable import bitchat diff --git a/bitchatTests/Integration/IntegrationTests.swift b/bitchatTests/Integration/IntegrationTests.swift index 52b4d2de..72ef585f 100644 --- a/bitchatTests/Integration/IntegrationTests.swift +++ b/bitchatTests/Integration/IntegrationTests.swift @@ -9,6 +9,7 @@ import Foundation import CryptoKit import Testing +import Noise @testable import BitFoundation // to avoid unnecessary public's @testable import bitchat diff --git a/bitchatTests/Integration/TestNetworkHelper.swift b/bitchatTests/Integration/TestNetworkHelper.swift index 277e1f52..c81fccb4 100644 --- a/bitchatTests/Integration/TestNetworkHelper.swift +++ b/bitchatTests/Integration/TestNetworkHelper.swift @@ -8,6 +8,7 @@ import Foundation import CryptoKit +import Noise @testable import BitFoundation // to avoid unnecessary public's @testable import bitchat diff --git a/localPackages/Noise/Package.swift b/localPackages/Noise/Package.swift new file mode 100644 index 00000000..805ad9e4 --- /dev/null +++ b/localPackages/Noise/Package.swift @@ -0,0 +1,38 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "Noise", + platforms: [ + .iOS(.v16), + .macOS(.v13) + ], + products: [ + .library( + name: "Noise", + targets: ["Noise"] + ), + ], + dependencies: [ + .package(path: "../BitLogger"), + .package(path: "../BitFoundation"), + ], + targets: [ + .target( + name: "Noise", + dependencies: [ + .product(name: "BitLogger", package: "BitLogger"), + .product(name: "BitFoundation", package: "BitFoundation"), + ], + path: "Sources" + ), + .testTarget( + name: "NoiseTests", + dependencies: ["Noise"], + resources: [ + .process("NoiseTestVectors.json") + ] + ), + ] +) diff --git a/localPackages/Noise/Sources/Noise/NoiseEncryptionService.swift b/localPackages/Noise/Sources/Noise/NoiseEncryptionService.swift new file mode 100644 index 00000000..70e43c0d --- /dev/null +++ b/localPackages/Noise/Sources/Noise/NoiseEncryptionService.swift @@ -0,0 +1,772 @@ +// +// NoiseEncryptionService.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +/// +/// # NoiseEncryptionService +/// +/// High-level encryption service that manages Noise Protocol sessions for secure +/// peer-to-peer communication in BitChat. Acts as the bridge between the transport +/// layer (BLEService) and the cryptographic layer (NoiseProtocol). +/// +/// ## Overview +/// This service provides a simplified API for establishing and managing encrypted +/// channels between peers. It handles: +/// - Static identity key management +/// - Session lifecycle (creation, maintenance, teardown) +/// - Message encryption/decryption +/// - Peer authentication and fingerprint tracking +/// - Automatic rekeying for forward secrecy +/// +/// ## Architecture +/// The service operates at multiple levels: +/// 1. **Identity Management**: Persistent Curve25519 keys stored in Keychain +/// 2. **Session Management**: Per-peer Noise sessions with state tracking +/// 3. **Message Processing**: Encryption/decryption with proper framing +/// 4. **Security Features**: Rate limiting, fingerprint verification +/// +/// ## Key Features +/// +/// ### Identity Keys +/// - Static Curve25519 key pair for Noise XX pattern +/// - Ed25519 signing key pair for additional authentication +/// - Keys persisted securely in iOS/macOS Keychain +/// - Fingerprints derived from SHA256 of public keys +/// +/// ### Session Management +/// - Lazy session creation (on-demand when sending messages) +/// - Automatic session recovery after disconnections +/// - Configurable rekey intervals for forward secrecy +/// - Graceful handling of simultaneous handshakes +/// +/// ### Security Properties +/// - Forward secrecy via ephemeral keys in handshakes +/// - Mutual authentication via static key exchange +/// - Protection against replay attacks +/// - Rate limiting to prevent DoS attacks +/// +/// ## Encryption Flow +/// ``` +/// 1. Message arrives for encryption +/// 2. Check if session exists for peer +/// 3. If not, initiate Noise handshake +/// 4. Once established, encrypt message +/// 5. Add message type header for protocol handling +/// 6. Return encrypted payload for transmission +/// ``` +/// +/// ## Integration Points +/// - **BLEService**: Calls this service for all private messages +/// - **ChatViewModel**: Monitors encryption status for UI indicators +/// - **KeychainManager**: Secure storage for identity keys +/// +/// ## Thread Safety +/// - Concurrent read access via reader-writer queue +/// - Session operations protected by per-peer queues +/// - Atomic updates for critical state changes +/// +/// ## Error Handling +/// - Graceful fallback for encryption failures +/// - Clear error messages for debugging +/// - Automatic retry with exponential backoff +/// - User notification for critical failures +/// +/// ## Performance Considerations +/// - Sessions cached in memory for fast access +/// - Minimal allocations in hot paths +/// - Efficient binary message format +/// - Background queue for CPU-intensive operations +/// + +import BitLogger +import BitFoundation +import Foundation +import CryptoKit + +// MARK: - Encryption Status + +/// Represents the current encryption status of a peer connection. +/// Used for UI indicators and decision-making about message handling. +enum EncryptionStatus: Equatable { + case none // Failed or incompatible + case noHandshake // No handshake attempted yet + case noiseHandshaking // Currently establishing + case noiseSecured // Established but not verified + case noiseVerified // Established and verified + + var icon: String? { // Made optional to hide icon when no handshake + switch self { + case .none: + return "lock.slash" // Failed handshake + case .noHandshake: + return nil // No icon when no handshake attempted + case .noiseHandshaking: + return "lock.rotation" + case .noiseSecured: + return "lock.fill" // Changed from "lock" to "lock.fill" for filled lock + case .noiseVerified: + return "checkmark.seal.fill" // Verified badge + } + } + + var description: String { + switch self { + case .none: + return String(localized: "encryption.status.failed", comment: "Status text when encryption failed") + case .noHandshake: + return String(localized: "encryption.status.not_encrypted", comment: "Status text when no encryption handshake happened") + case .noiseHandshaking: + return String(localized: "encryption.status.establishing", comment: "Status text when encryption is being established") + case .noiseSecured: + return String(localized: "encryption.status.secured", comment: "Status text when encryption is secured but not verified") + case .noiseVerified: + return String(localized: "encryption.status.verified", comment: "Status text when encryption is verified") + } + } + + var accessibilityDescription: String { + switch self { + case .none: + return String(localized: "encryption.accessibility.failed", comment: "Accessibility text when encryption failed") + case .noHandshake: + return String(localized: "encryption.accessibility.not_encrypted", comment: "Accessibility text when encryption is not established") + case .noiseHandshaking: + return String(localized: "encryption.accessibility.establishing", comment: "Accessibility text when encryption is being established") + case .noiseSecured: + return String(localized: "encryption.accessibility.secured", comment: "Accessibility text when encryption is secured") + case .noiseVerified: + return String(localized: "encryption.accessibility.verified", comment: "Accessibility text when encryption is verified") + } + } +} + +// MARK: - Noise Encryption Service + +/// Manages end-to-end encryption for BitChat using the Noise Protocol Framework. +/// Provides a high-level API for establishing secure channels between peers, +/// handling all cryptographic operations transparently. +/// - Important: This service maintains the device's cryptographic identity +final class NoiseEncryptionService { + // Static identity key (persistent across sessions) + private let staticIdentityKey: Curve25519.KeyAgreement.PrivateKey + public let staticIdentityPublicKey: Curve25519.KeyAgreement.PublicKey + + // Ed25519 signing key (persistent across sessions) + private let signingKey: Curve25519.Signing.PrivateKey + public let signingPublicKey: Curve25519.Signing.PublicKey + + // Session manager + private let sessionManager: NoiseSessionManager + + // Peer fingerprints (SHA256 hash of static public key) + private var peerFingerprints: [PeerID: String] = [:] + private var fingerprintToPeerID: [String: PeerID] = [:] + + // Thread safety + private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent) + + // Security components + private let rateLimiter = NoiseRateLimiter() + private let keychain: KeychainManagerProtocol + + // Session maintenance + private var rekeyTimer: Timer? + private let rekeyCheckInterval: TimeInterval = 60.0 // Check every minute + + // Callbacks + private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication + var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake + + // Add a handler for peer authentication + func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) { + serviceQueue.async(flags: .barrier) { [weak self] in + self?.onPeerAuthenticatedHandlers.append(handler) + } + } + + // Legacy support - setting this will add to the handlers array + var onPeerAuthenticated: ((PeerID, String) -> Void)? { + get { nil } // Always return nil for backward compatibility + set { + if let handler = newValue { + addOnPeerAuthenticatedHandler(handler) + } + } + } + + init(keychain: KeychainManagerProtocol) { + self.keychain = keychain + + // BCH-01-009: Load or create static identity key with proper error handling + let loadedKey: Curve25519.KeyAgreement.PrivateKey + + // Try to load from keychain with proper error classification + let noiseKeyResult = keychain.getIdentityKeyWithResult(forKey: "noiseStaticKey") + + switch noiseKeyResult { + case .success(let identityData): + if let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: identityData) { + loadedKey = key + SecureLogger.logKeyOperation(.load, keyType: "noiseStaticKey", success: true) + } else { + // Data corrupted, regenerate + SecureLogger.warning("Noise static key data corrupted, regenerating", category: .keychain) + loadedKey = Self.generateAndSaveNoiseKey(keychain: keychain) + } + + case .itemNotFound: + // Expected case: no key exists yet, create new one + loadedKey = Self.generateAndSaveNoiseKey(keychain: keychain) + + case .accessDenied: + // Critical error - log but proceed with ephemeral key (will be lost on restart) + SecureLogger.error(NSError(domain: "Keychain", code: -1), + context: "Keychain access denied - using ephemeral identity", category: .keychain) + loadedKey = Curve25519.KeyAgreement.PrivateKey() + + case .deviceLocked, .authenticationFailed: + // Recoverable error - use ephemeral key and warn + SecureLogger.warning("Device locked or auth failed - using ephemeral identity until unlocked", category: .keychain) + loadedKey = Curve25519.KeyAgreement.PrivateKey() + + case .otherError(let status): + // Unexpected error - log and use ephemeral key + SecureLogger.error(NSError(domain: "Keychain", code: Int(status)), + context: "Unexpected keychain error - using ephemeral identity", category: .keychain) + loadedKey = Curve25519.KeyAgreement.PrivateKey() + } + + // Now assign the final value + self.staticIdentityKey = loadedKey + self.staticIdentityPublicKey = staticIdentityKey.publicKey + + // BCH-01-009: Load or create signing key pair with proper error handling + let loadedSigningKey: Curve25519.Signing.PrivateKey + + let signingKeyResult = keychain.getIdentityKeyWithResult(forKey: "ed25519SigningKey") + + switch signingKeyResult { + case .success(let signingData): + if let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingData) { + loadedSigningKey = key + SecureLogger.logKeyOperation(.load, keyType: "ed25519SigningKey", success: true) + } else { + // Data corrupted, regenerate + SecureLogger.warning("Ed25519 signing key data corrupted, regenerating", category: .keychain) + loadedSigningKey = Self.generateAndSaveSigningKey(keychain: keychain) + } + + case .itemNotFound: + // Expected case: no key exists yet, create new one + loadedSigningKey = Self.generateAndSaveSigningKey(keychain: keychain) + + case .accessDenied: + // Critical error - log but proceed with ephemeral key + SecureLogger.error(NSError(domain: "Keychain", code: -1), + context: "Keychain access denied - using ephemeral signing key", category: .keychain) + loadedSigningKey = Curve25519.Signing.PrivateKey() + + case .deviceLocked, .authenticationFailed: + // Recoverable error - use ephemeral key and warn + SecureLogger.warning("Device locked or auth failed - using ephemeral signing key until unlocked", category: .keychain) + loadedSigningKey = Curve25519.Signing.PrivateKey() + + case .otherError(let status): + // Unexpected error - log and use ephemeral key + SecureLogger.error(NSError(domain: "Keychain", code: Int(status)), + context: "Unexpected keychain error - using ephemeral signing key", category: .keychain) + loadedSigningKey = Curve25519.Signing.PrivateKey() + } + + // Now assign the signing keys + self.signingKey = loadedSigningKey + self.signingPublicKey = signingKey.publicKey + + // Initialize session manager + self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain) + + // Set up session callbacks + sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in + self?.handleSessionEstablished(peerID: peerID, remoteStaticKey: remoteStaticKey) + } + + // Start session maintenance timer + startRekeyTimer() + } + + // MARK: - BCH-01-009: Key Generation Helpers with Save Verification + + /// Generate and save a new Noise static key, verifying the save succeeds + private static func generateAndSaveNoiseKey(keychain: KeychainManagerProtocol) -> Curve25519.KeyAgreement.PrivateKey { + let newKey = Curve25519.KeyAgreement.PrivateKey() + let keyData = newKey.rawRepresentation + + // Save to keychain and verify success + let saveResult = keychain.saveIdentityKeyWithResult(keyData, forKey: "noiseStaticKey") + + switch saveResult { + case .success: + SecureLogger.logKeyOperation(.create, keyType: "noiseStaticKey", success: true) + case .duplicateItem: + // This shouldn't happen since we just tried to load, but handle it + SecureLogger.warning("Noise key already exists (race condition?)", category: .keychain) + default: + // Save failed - log but continue with the key (it will be ephemeral) + SecureLogger.error(NSError(domain: "Keychain", code: -1), + context: "Failed to persist noise static key - identity will be lost on restart", + category: .keychain) + } + + return newKey + } + + /// Generate and save a new Ed25519 signing key, verifying the save succeeds + private static func generateAndSaveSigningKey(keychain: KeychainManagerProtocol) -> Curve25519.Signing.PrivateKey { + let newKey = Curve25519.Signing.PrivateKey() + let keyData = newKey.rawRepresentation + + // Save to keychain and verify success + let saveResult = keychain.saveIdentityKeyWithResult(keyData, forKey: "ed25519SigningKey") + + switch saveResult { + case .success: + SecureLogger.logKeyOperation(.create, keyType: "ed25519SigningKey", success: true) + case .duplicateItem: + // This shouldn't happen since we just tried to load, but handle it + SecureLogger.warning("Signing key already exists (race condition?)", category: .keychain) + default: + // Save failed - log but continue with the key (it will be ephemeral) + SecureLogger.error(NSError(domain: "Keychain", code: -1), + context: "Failed to persist signing key - identity will be lost on restart", + category: .keychain) + } + + return newKey + } + + // MARK: - Public Interface + + /// Get our static public key for sharing + func getStaticPublicKeyData() -> Data { + return staticIdentityPublicKey.rawRepresentation + } + + /// Get our signing public key for sharing + func getSigningPublicKeyData() -> Data { + return signingPublicKey.rawRepresentation + } + + /// Get our identity fingerprint + func getIdentityFingerprint() -> String { + staticIdentityPublicKey.rawRepresentation.sha256Fingerprint() + } + + /// Get peer's public key data + func getPeerPublicKeyData(_ peerID: PeerID) -> Data? { + return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation + } + + /// Clear persistent identity (for panic mode) + func clearPersistentIdentity() { + // Clear from keychain + let deletedStatic = keychain.deleteIdentityKey(forKey: "noiseStaticKey") + let deletedSigning = keychain.deleteIdentityKey(forKey: "ed25519SigningKey") + SecureLogger.logKeyOperation(.delete, keyType: "identity keys", success: deletedStatic && deletedSigning) + SecureLogger.warning("Panic mode activated - identity cleared", category: .security) + // Stop rekey timer + stopRekeyTimer() + } + + /// Sign data with our Ed25519 signing key + func signData(_ data: Data) -> Data? { + do { + let signature = try signingKey.signature(for: data) + return signature + } catch { + SecureLogger.error(error, context: "Failed to sign data") + return nil + } + } + + /// Verify signature with a peer's Ed25519 public key + func verifySignature(_ signature: Data, for data: Data, publicKey: Data) -> Bool { + do { + let signingPublicKey = try Curve25519.Signing.PublicKey(rawRepresentation: publicKey) + return signingPublicKey.isValidSignature(signature, for: data) + } catch { + SecureLogger.error(error, context: "Failed to verify signature") + return false + } + } + + // MARK: - Announce Signature Helpers + + /// Build the canonical announce binding message bytes and sign with our Ed25519 key + /// - Parameters: + /// - peerID: 8-byte routing ID (as in packet header) + /// - noiseKey: 32-byte Curve25519.KeyAgreement public key + /// - ed25519Key: 32-byte Ed25519 public key (self) + /// - nickname: UTF-8 nickname (<=255 bytes) + /// - timestampMs: UInt64 milliseconds since epoch + /// - Returns: Ed25519 signature over the canonical bytes, or nil on failure + func buildAnnounceSignature(peerID: Data, noiseKey: Data, ed25519Key: Data, nickname: String, timestampMs: UInt64) -> Data? { + let message = canonicalAnnounceBytes(peerID: peerID, noiseKey: noiseKey, ed25519Key: ed25519Key, nickname: nickname, timestampMs: timestampMs) + return signData(message) + } + + /// Verify an announce signature + func verifyAnnounceSignature(signature: Data, peerID: Data, noiseKey: Data, ed25519Key: Data, nickname: String, timestampMs: UInt64, publicKey: Data) -> Bool { + let message = canonicalAnnounceBytes(peerID: peerID, noiseKey: noiseKey, ed25519Key: ed25519Key, nickname: nickname, timestampMs: timestampMs) + return verifySignature(signature, for: message, publicKey: publicKey) + } + + /// Build canonical bytes for announce signing. + private func canonicalAnnounceBytes(peerID: Data, noiseKey: Data, ed25519Key: Data, nickname: String, timestampMs: UInt64) -> Data { + var out = Data() + // context + let context = "bitchat-announce-v1".data(using: .utf8) ?? Data() + out.append(UInt8(min(context.count, 255))) + out.append(context.prefix(255)) + // peerID (expect 8 bytes; pad/truncate to 8 for canonicalization) + let peerID8 = peerID.prefix(8) + out.append(peerID8) + if peerID8.count < 8 { out.append(Data(repeating: 0, count: 8 - peerID8.count)) } + // noise static key (expect 32) + let noise32 = noiseKey.prefix(32) + out.append(noise32) + if noise32.count < 32 { out.append(Data(repeating: 0, count: 32 - noise32.count)) } + // ed25519 public key (expect 32) + let ed32 = ed25519Key.prefix(32) + out.append(ed32) + if ed32.count < 32 { out.append(Data(repeating: 0, count: 32 - ed32.count)) } + // nickname length + bytes + let nickData = nickname.data(using: .utf8) ?? Data() + out.append(UInt8(min(nickData.count, 255))) + out.append(nickData.prefix(255)) + // timestamp + var ts = timestampMs.bigEndian + withUnsafeBytes(of: &ts) { raw in out.append(contentsOf: raw) } + return out + } + + // MARK: - Packet Signing/Verification + + /// Sign a BitchatPacket using the noise private key + func signPacket(_ packet: BitchatPacket) -> BitchatPacket? { + // Create canonical packet bytes for signing + guard let packetData = packet.toBinaryDataForSigning() else { + return nil + } + + // Sign with the noise private key (converted to Ed25519 for signing) + guard let signature = signData(packetData) else { + return nil + } + + // Return new packet with signature + var signedPacket = packet + signedPacket.signature = signature + return signedPacket + } + + /// Verify a BitchatPacket signature using the provided public key + func verifyPacketSignature(_ packet: BitchatPacket, publicKey: Data) -> Bool { + guard let signature = packet.signature else { + return false + } + + // Create canonical packet bytes for verification (without signature) + + guard let packetData = packet.toBinaryDataForSigning() else { + return false + } + + // For noise public keys, we need to derive the Ed25519 key for verification + // This assumes the noise key can be used for Ed25519 signing + return verifySignature(signature, for: packetData, publicKey: publicKey) + } + + + // MARK: - Handshake Management + + /// Initiate a Noise handshake with a peer + func initiateHandshake(with peerID: PeerID) throws -> Data { + + // Validate peer ID + guard peerID.isValid else { + SecureLogger.warning(.authenticationFailed(peerID: peerID.id)) + throw NoiseSecurityError.invalidPeerID + } + + // Check rate limit + guard rateLimiter.allowHandshake(from: peerID) else { + SecureLogger.warning(.authenticationFailed(peerID: "Rate limited: \(peerID)")) + throw NoiseSecurityError.rateLimitExceeded + } + + SecureLogger.info(.handshakeStarted(peerID: peerID.id)) + + // Return raw handshake data without wrapper + // The Noise protocol handles its own message format + let handshakeData = try sessionManager.initiateHandshake(with: peerID) + return handshakeData + } + + /// Process an incoming handshake message + func processHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? { + + // Validate peer ID + guard peerID.isValid else { + SecureLogger.warning(.authenticationFailed(peerID: peerID.id)) + throw NoiseSecurityError.invalidPeerID + } + + // Validate message size + guard NoiseSecurityValidator.validateHandshakeMessageSize(message) else { + SecureLogger.warning(.handshakeFailed(peerID: peerID.id, error: "Message too large")) + throw NoiseSecurityError.messageTooLarge + } + + // Check rate limit + guard rateLimiter.allowHandshake(from: peerID) else { + SecureLogger.warning(.authenticationFailed(peerID: "Rate limited: \(peerID)")) + throw NoiseSecurityError.rateLimitExceeded + } + + // For handshakes, we process the raw data directly without NoiseMessage wrapper + // The Noise protocol handles its own message format + let responsePayload = try sessionManager.handleIncomingHandshake(from: peerID, message: message) + + + // Return raw response without wrapper + return responsePayload + } + + /// Check if we have an established session with a peer + func hasEstablishedSession(with peerID: PeerID) -> Bool { + return sessionManager.getSession(for: peerID)?.isEstablished() ?? false + } + + /// Check if we have a session (established or handshaking) with a peer + func hasSession(with peerID: PeerID) -> Bool { + return sessionManager.getSession(for: peerID) != nil + } + + // MARK: - Encryption/Decryption + + /// Encrypt data for a specific peer + func encrypt(_ data: Data, for peerID: PeerID) throws -> Data { + // Validate message size + guard NoiseSecurityValidator.validateMessageSize(data) else { + throw NoiseSecurityError.messageTooLarge + } + + // Check rate limit + guard rateLimiter.allowMessage(from: peerID) else { + throw NoiseSecurityError.rateLimitExceeded + } + + // Check if we have an established session + guard hasEstablishedSession(with: peerID) else { + // Signal that handshake is needed + onHandshakeRequired?(peerID) + throw NoiseEncryptionError.handshakeRequired + } + + return try sessionManager.encrypt(data, for: peerID) + } + + /// Decrypt data from a specific peer + func decrypt(_ data: Data, from peerID: PeerID) throws -> Data { + // Validate message size + guard NoiseSecurityValidator.validateMessageSize(data) else { + throw NoiseSecurityError.messageTooLarge + } + + // Check rate limit + guard rateLimiter.allowMessage(from: peerID) else { + throw NoiseSecurityError.rateLimitExceeded + } + + // Check if we have an established session + guard hasEstablishedSession(with: peerID) else { + throw NoiseEncryptionError.sessionNotEstablished + } + + return try sessionManager.decrypt(data, from: peerID) + } + + // MARK: - Peer Management + + /// Get fingerprint for a peer + func getPeerFingerprint(_ peerID: PeerID) -> String? { + return serviceQueue.sync { + return peerFingerprints[peerID] + } + } + + func clearEphemeralStateForPanic() { + sessionManager.removeAllSessions() + serviceQueue.sync(flags: .barrier) { + peerFingerprints.removeAll() + fingerprintToPeerID.removeAll() + } + rateLimiter.resetAll() + } + + /// Clear session for a specific peer (e.g., on decryption failure to allow re-handshake) + func clearSession(for peerID: PeerID) { + sessionManager.removeSession(for: peerID) + serviceQueue.sync(flags: .barrier) { + if let fingerprint = peerFingerprints.removeValue(forKey: peerID) { + fingerprintToPeerID.removeValue(forKey: fingerprint) + } + } + SecureLogger.debug("🔓 Cleared Noise session for \(peerID)", category: .session) + } + + // MARK: - Private Helpers + + private func handleSessionEstablished(peerID: PeerID, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) { + // Calculate fingerprint + let fingerprint = remoteStaticKey.rawRepresentation.sha256Fingerprint() + + // Store fingerprint mapping + serviceQueue.sync(flags: .barrier) { + peerFingerprints[peerID] = fingerprint + fingerprintToPeerID[fingerprint] = peerID + } + + // Log security event + SecureLogger.info(.handshakeCompleted(peerID: peerID.id)) + + // Notify all handlers about authentication + serviceQueue.async { [weak self] in + self?.onPeerAuthenticatedHandlers.forEach { handler in + handler(peerID, fingerprint) + } + } + } + + // MARK: - Session Maintenance + + private func startRekeyTimer() { + rekeyTimer = Timer.scheduledTimer(withTimeInterval: rekeyCheckInterval, repeats: true) { [weak self] _ in + self?.checkSessionsForRekey() + } + } + + private func stopRekeyTimer() { + rekeyTimer?.invalidate() + rekeyTimer = nil + } + + private func checkSessionsForRekey() { + let sessionsNeedingRekey = sessionManager.getSessionsNeedingRekey() + + for (peerID, needsRekey) in sessionsNeedingRekey where needsRekey { + + // Attempt to rekey the session + do { + try sessionManager.initiateRekey(for: peerID) + SecureLogger.debug("Key rotation initiated for peer: \(peerID)", category: .security) + + // Signal that handshake is needed + onHandshakeRequired?(peerID) + } catch { + SecureLogger.error(error, context: "Failed to initiate rekey for peer: \(peerID)", category: .session) + } + } + } + + deinit { + stopRekeyTimer() + } +} + +// MARK: - Protocol Message Types for Noise + +/// Message types for the Noise encryption protocol layer. +/// These types wrap the underlying BitChat protocol messages with encryption metadata. +enum NoiseMessageType: UInt8 { + case handshakeInitiation = 0x10 + case handshakeResponse = 0x11 + case handshakeFinal = 0x12 + case encryptedMessage = 0x13 + case sessionRenegotiation = 0x14 +} + +// MARK: - Noise Message Wrapper + +/// Container for encrypted messages in the Noise protocol. +/// Provides versioning and type information for proper message handling. +/// The actual message content is encrypted in the payload field. +struct NoiseMessage: Codable { + let type: UInt8 + let sessionID: String // Random ID for this handshake session + let payload: Data + + init(type: NoiseMessageType, sessionID: String, payload: Data) { + self.type = type.rawValue + self.sessionID = sessionID + self.payload = payload + } + + func encode() -> Data? { + do { + let encoded = try JSONEncoder().encode(self) + return encoded + } catch { + return nil + } + } + + static func decode(from data: Data) -> NoiseMessage? { + return try? JSONDecoder().decode(NoiseMessage.self, from: data) + } + + static func decodeWithError(from data: Data) -> NoiseMessage? { + do { + let decoded = try JSONDecoder().decode(NoiseMessage.self, from: data) + return decoded + } catch { + return nil + } + } + + // MARK: - Binary Encoding + + func toBinaryData() -> Data { + var data = Data() + data.appendUInt8(type) + data.appendUUID(sessionID) + data.appendData(payload) + return data + } + + static func fromBinaryData(_ data: Data) -> NoiseMessage? { + // Create defensive copy + let dataCopy = Data(data) + + var offset = 0 + + guard let type = dataCopy.readUInt8(at: &offset), + let sessionID = dataCopy.readUUID(at: &offset), + let payload = dataCopy.readData(at: &offset) else { return nil } + + guard let messageType = NoiseMessageType(rawValue: type) else { return nil } + + return NoiseMessage(type: messageType, sessionID: sessionID, payload: payload) + } +} + +// MARK: - Errors + +enum NoiseEncryptionError: Error { + case handshakeRequired + case sessionNotEstablished +} diff --git a/bitchat/Noise/NoiseProtocol.swift b/localPackages/Noise/Sources/Noise/NoiseProtocol.swift similarity index 99% rename from bitchat/Noise/NoiseProtocol.swift rename to localPackages/Noise/Sources/Noise/NoiseProtocol.swift index 665a5001..188c547c 100644 --- a/bitchat/Noise/NoiseProtocol.swift +++ b/localPackages/Noise/Sources/Noise/NoiseProtocol.swift @@ -924,7 +924,7 @@ extension NoisePattern { // MARK: - Errors -enum NoiseError: Error { +public enum NoiseError: Error { case uninitializedCipher case invalidCiphertext case handshakeComplete diff --git a/bitchat/Noise/NoiseRateLimiter.swift b/localPackages/Noise/Sources/Noise/NoiseRateLimiter.swift similarity index 94% rename from bitchat/Noise/NoiseRateLimiter.swift rename to localPackages/Noise/Sources/Noise/NoiseRateLimiter.swift index 7581244f..fccb6000 100644 --- a/bitchat/Noise/NoiseRateLimiter.swift +++ b/localPackages/Noise/Sources/Noise/NoiseRateLimiter.swift @@ -10,7 +10,7 @@ import BitLogger import BitFoundation import Foundation -final class NoiseRateLimiter { +public final class NoiseRateLimiter { private var handshakeTimestamps: [PeerID: [Date]] = [:] private var messageTimestamps: [PeerID: [Date]] = [:] @@ -19,8 +19,10 @@ final class NoiseRateLimiter { private var globalMessageTimestamps: [Date] = [] private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent) - - func allowHandshake(from peerID: PeerID) -> Bool { + + public init() {} + + public func allowHandshake(from peerID: PeerID) -> Bool { return queue.sync(flags: .barrier) { let now = Date() let oneMinuteAgo = now.addingTimeInterval(-60) @@ -49,7 +51,7 @@ final class NoiseRateLimiter { } } - func allowMessage(from peerID: PeerID) -> Bool { + public func allowMessage(from peerID: PeerID) -> Bool { return queue.sync(flags: .barrier) { let now = Date() let oneSecondAgo = now.addingTimeInterval(-1) @@ -85,7 +87,7 @@ final class NoiseRateLimiter { } } - func resetAll() { + public func resetAll() { queue.async(flags: .barrier) { self.handshakeTimestamps.removeAll() self.messageTimestamps.removeAll() diff --git a/bitchat/Noise/NoiseSecurityConstants.swift b/localPackages/Noise/Sources/Noise/NoiseSecurityConstants.swift similarity index 80% rename from bitchat/Noise/NoiseSecurityConstants.swift rename to localPackages/Noise/Sources/Noise/NoiseSecurityConstants.swift index 17781352..8b30299c 100644 --- a/bitchat/Noise/NoiseSecurityConstants.swift +++ b/localPackages/Noise/Sources/Noise/NoiseSecurityConstants.swift @@ -8,7 +8,7 @@ import Foundation -enum NoiseSecurityConstants { +public enum NoiseSecurityConstants { // Maximum message size to prevent memory exhaustion static let maxMessageSize = 65535 // 64KB as per Noise spec @@ -28,10 +28,10 @@ enum NoiseSecurityConstants { static let maxSessionsPerPeer = 3 // Rate limiting - static let maxHandshakesPerMinute = 10 - static let maxMessagesPerSecond = 100 + public static let maxHandshakesPerMinute = 10 + public static let maxMessagesPerSecond = 100 // Global rate limiting (across all peers) - static let maxGlobalHandshakesPerMinute = 30 - static let maxGlobalMessagesPerSecond = 500 + public static let maxGlobalHandshakesPerMinute = 30 + public static let maxGlobalMessagesPerSecond = 500 } diff --git a/bitchat/Noise/NoiseSecurityError.swift b/localPackages/Noise/Sources/Noise/NoiseSecurityError.swift similarity index 89% rename from bitchat/Noise/NoiseSecurityError.swift rename to localPackages/Noise/Sources/Noise/NoiseSecurityError.swift index aff73c8b..5663af0a 100644 --- a/bitchat/Noise/NoiseSecurityError.swift +++ b/localPackages/Noise/Sources/Noise/NoiseSecurityError.swift @@ -8,7 +8,7 @@ import Foundation -enum NoiseSecurityError: Error { +public enum NoiseSecurityError: Error { case sessionExpired case sessionExhausted case messageTooLarge diff --git a/bitchat/Noise/NoiseSecurityValidator.swift b/localPackages/Noise/Sources/Noise/NoiseSecurityValidator.swift similarity index 70% rename from bitchat/Noise/NoiseSecurityValidator.swift rename to localPackages/Noise/Sources/Noise/NoiseSecurityValidator.swift index 355d8fd7..f2a02e00 100644 --- a/bitchat/Noise/NoiseSecurityValidator.swift +++ b/localPackages/Noise/Sources/Noise/NoiseSecurityValidator.swift @@ -8,15 +8,15 @@ import Foundation -struct NoiseSecurityValidator { +public struct NoiseSecurityValidator { /// Validate message size - static func validateMessageSize(_ data: Data) -> Bool { + public static func validateMessageSize(_ data: Data) -> Bool { return data.count <= NoiseSecurityConstants.maxMessageSize } /// Validate handshake message size - static func validateHandshakeMessageSize(_ data: Data) -> Bool { + public static func validateHandshakeMessageSize(_ data: Data) -> Bool { return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize } } diff --git a/bitchat/Noise/NoiseSession.swift b/localPackages/Noise/Sources/Noise/NoiseSession.swift similarity index 98% rename from bitchat/Noise/NoiseSession.swift rename to localPackages/Noise/Sources/Noise/NoiseSession.swift index 1df2f0ba..5436b3ee 100644 --- a/bitchat/Noise/NoiseSession.swift +++ b/localPackages/Noise/Sources/Noise/NoiseSession.swift @@ -11,7 +11,7 @@ import Foundation import CryptoKit import BitFoundation -class NoiseSession { +public class NoiseSession { let peerID: PeerID let role: NoiseRole private let keychain: KeychainManagerProtocol @@ -182,7 +182,7 @@ class NoiseSession { } } - func isEstablished() -> Bool { + public func isEstablished() -> Bool { return sessionQueue.sync { if case .established = state { return true @@ -217,8 +217,8 @@ class NoiseSession { sentHandshakeMessages.removeAll() // Clear handshake hash - if var hash = handshakeHash { - keychain.secureClear(&hash) + if handshakeHash != nil { + keychain.secureClear(&handshakeHash!) } handshakeHash = nil diff --git a/bitchat/Noise/NoiseSessionError.swift b/localPackages/Noise/Sources/Noise/NoiseSessionError.swift similarity index 84% rename from bitchat/Noise/NoiseSessionError.swift rename to localPackages/Noise/Sources/Noise/NoiseSessionError.swift index 24e172e7..0cda921c 100644 --- a/bitchat/Noise/NoiseSessionError.swift +++ b/localPackages/Noise/Sources/Noise/NoiseSessionError.swift @@ -6,7 +6,7 @@ // For more information, see // -enum NoiseSessionError: Error, Equatable { +public enum NoiseSessionError: Error, Equatable { case invalidState case notEstablished case sessionNotFound diff --git a/bitchat/Noise/NoiseSessionManager.swift b/localPackages/Noise/Sources/Noise/NoiseSessionManager.swift similarity index 88% rename from bitchat/Noise/NoiseSessionManager.swift rename to localPackages/Noise/Sources/Noise/NoiseSessionManager.swift index 9029b228..c9c9a1f4 100644 --- a/bitchat/Noise/NoiseSessionManager.swift +++ b/localPackages/Noise/Sources/Noise/NoiseSessionManager.swift @@ -11,7 +11,7 @@ import CryptoKit import Foundation import BitFoundation -final class NoiseSessionManager { +public final class NoiseSessionManager { private var sessions: [PeerID: NoiseSession] = [:] private let localStaticKey: Curve25519.KeyAgreement.PrivateKey private let keychain: KeychainManagerProtocol @@ -19,10 +19,10 @@ final class NoiseSessionManager { private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent) // Callbacks - var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey) -> Void)? + public var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey) -> Void)? var onSessionFailed: ((PeerID, Error) -> Void)? - init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) { + public init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) { self.localStaticKey = localStaticKey self.keychain = keychain self.sessionFactory = { peerID, role in @@ -49,13 +49,13 @@ final class NoiseSessionManager { // MARK: - Session Management - func getSession(for peerID: PeerID) -> NoiseSession? { + public func getSession(for peerID: PeerID) -> NoiseSession? { return managerQueue.sync { return sessions[peerID] } } - func removeSession(for peerID: PeerID) { + public func removeSession(for peerID: PeerID) { managerQueue.sync(flags: .barrier) { if let session = sessions.removeValue(forKey: peerID) { session.reset() // Clear sensitive data before removing @@ -63,7 +63,7 @@ final class NoiseSessionManager { } } - func removeAllSessions() { + public func removeAllSessions() { managerQueue.sync(flags: .barrier) { for (_, session) in sessions { session.reset() @@ -74,7 +74,7 @@ final class NoiseSessionManager { // MARK: - Handshake Helpers - func initiateHandshake(with peerID: PeerID) throws -> Data { + public func initiateHandshake(with peerID: PeerID) throws -> Data { return try managerQueue.sync(flags: .barrier) { // Check if we already have an established session if let existingSession = sessions[peerID], existingSession.isEstablished() { @@ -103,7 +103,7 @@ final class NoiseSessionManager { } } - func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? { + public func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? { // Process everything within the synchronized block to prevent race conditions return try managerQueue.sync(flags: .barrier) { var shouldCreateNew = false @@ -173,7 +173,7 @@ final class NoiseSessionManager { // MARK: - Encryption/Decryption - func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data { + public func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data { guard let session = getSession(for: peerID) else { throw NoiseSessionError.sessionNotFound } @@ -181,7 +181,7 @@ final class NoiseSessionManager { return try session.encrypt(plaintext) } - func decrypt(_ ciphertext: Data, from peerID: PeerID) throws -> Data { + public func decrypt(_ ciphertext: Data, from peerID: PeerID) throws -> Data { guard let session = getSession(for: peerID) else { throw NoiseSessionError.sessionNotFound } @@ -191,13 +191,13 @@ final class NoiseSessionManager { // MARK: - Key Management - func getRemoteStaticKey(for peerID: PeerID) -> Curve25519.KeyAgreement.PublicKey? { + public func getRemoteStaticKey(for peerID: PeerID) -> Curve25519.KeyAgreement.PublicKey? { return getSession(for: peerID)?.getRemoteStaticPublicKey() } // MARK: - Session Rekeying - func getSessionsNeedingRekey() -> [(peerID: PeerID, needsRekey: Bool)] { + public func getSessionsNeedingRekey() -> [(peerID: PeerID, needsRekey: Bool)] { return managerQueue.sync { var needingRekey: [(peerID: PeerID, needsRekey: Bool)] = [] @@ -213,7 +213,7 @@ final class NoiseSessionManager { } } - func initiateRekey(for peerID: PeerID) throws { + public func initiateRekey(for peerID: PeerID) throws { // Remove old session removeSession(for: peerID) diff --git a/bitchat/Noise/NoiseSessionState.swift b/localPackages/Noise/Sources/Noise/NoiseSessionState.swift similarity index 100% rename from bitchat/Noise/NoiseSessionState.swift rename to localPackages/Noise/Sources/Noise/NoiseSessionState.swift diff --git a/bitchat/Noise/SecureNoiseSession.swift b/localPackages/Noise/Sources/Noise/SecureNoiseSession.swift similarity index 100% rename from bitchat/Noise/SecureNoiseSession.swift rename to localPackages/Noise/Sources/Noise/SecureNoiseSession.swift diff --git a/localPackages/Noise/Tests/NoiseTests/MockKeychain.swift b/localPackages/Noise/Tests/NoiseTests/MockKeychain.swift new file mode 100644 index 00000000..5d4071ec --- /dev/null +++ b/localPackages/Noise/Tests/NoiseTests/MockKeychain.swift @@ -0,0 +1,201 @@ +// +// MockKeychain.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import BitFoundation + +// TODO: Create a module for test helpers +final class MockKeychain: KeychainManagerProtocol { + private var storage: [String: Data] = [:] + private var serviceStorage: [String: [String: Data]] = [:] + + // BCH-01-009: Configurable error simulation for testing + var simulatedReadError: KeychainReadResult? + var simulatedSaveError: KeychainSaveResult? + + func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool { + storage[key] = keyData + return true + } + + func getIdentityKey(forKey key: String) -> Data? { + storage[key] + } + + func deleteIdentityKey(forKey key: String) -> Bool { + storage.removeValue(forKey: key) + return true + } + + func deleteAllKeychainData() -> Bool { + storage.removeAll() + serviceStorage.removeAll() + return true + } + + func secureClear(_ data: inout Data) { + data = Data() + } + + func secureClear(_ string: inout String) { + string = "" + } + + func verifyIdentityKeyExists() -> Bool { + storage["identity_noiseStaticKey"] != nil + } + + // BCH-01-009: New methods with proper error classification + func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult { + if let simulated = simulatedReadError { + return simulated + } + if let data = storage[key] { + return .success(data) + } + return .itemNotFound + } + + func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult { + if let simulated = simulatedSaveError { + return simulated + } + storage[key] = keyData + return .success + } + + // MARK: - Generic Data Storage (consolidated from KeychainHelper) + + func save(key: String, data: Data, service: String, accessible: CFString?) { + if serviceStorage[service] == nil { + serviceStorage[service] = [:] + } + serviceStorage[service]?[key] = data + } + + func load(key: String, service: String) -> Data? { + serviceStorage[service]?[key] + } + + func delete(key: String, service: String) { + serviceStorage[service]?.removeValue(forKey: key) + } +} + +/// Typealias for backwards compatibility with tests using MockKeychainHelper +typealias MockKeychainHelper = MockKeychain + +/// Mock keychain that tracks secureClear calls for testing DH secret clearing +final class TrackingMockKeychain: KeychainManagerProtocol { + private var storage: [String: Data] = [:] + private var serviceStorage: [String: [String: Data]] = [:] + + /// Thread-safe counter for secureClear calls + private let lock = NSLock() + private var _secureClearDataCallCount = 0 + private var _secureClearStringCallCount = 0 + + // BCH-01-009: Configurable error simulation for testing + var simulatedReadError: KeychainReadResult? + var simulatedSaveError: KeychainSaveResult? + + var secureClearDataCallCount: Int { + lock.lock() + defer { lock.unlock() } + return _secureClearDataCallCount + } + + var secureClearStringCallCount: Int { + lock.lock() + defer { lock.unlock() } + return _secureClearStringCallCount + } + + var totalSecureClearCallCount: Int { + return secureClearDataCallCount + secureClearStringCallCount + } + + func resetCounts() { + lock.lock() + defer { lock.unlock() } + _secureClearDataCallCount = 0 + _secureClearStringCallCount = 0 + } + + func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool { + storage[key] = keyData + return true + } + + func getIdentityKey(forKey key: String) -> Data? { + storage[key] + } + + func deleteIdentityKey(forKey key: String) -> Bool { + storage.removeValue(forKey: key) + return true + } + + func deleteAllKeychainData() -> Bool { + storage.removeAll() + serviceStorage.removeAll() + return true + } + + func secureClear(_ data: inout Data) { + lock.lock() + _secureClearDataCallCount += 1 + lock.unlock() + data = Data() + } + + func secureClear(_ string: inout String) { + lock.lock() + _secureClearStringCallCount += 1 + lock.unlock() + string = "" + } + + func verifyIdentityKeyExists() -> Bool { + storage["identity_noiseStaticKey"] != nil + } + + // BCH-01-009: New methods with proper error classification + func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult { + if let simulated = simulatedReadError { + return simulated + } + if let data = storage[key] { + return .success(data) + } + return .itemNotFound + } + + func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult { + if let simulated = simulatedSaveError { + return simulated + } + storage[key] = keyData + return .success + } + + func save(key: String, data: Data, service: String, accessible: CFString?) { + if serviceStorage[service] == nil { + serviceStorage[service] = [:] + } + serviceStorage[service]?[key] = data + } + + func load(key: String, service: String) -> Data? { + serviceStorage[service]?[key] + } + + func delete(key: String, service: String) { + serviceStorage[service]?.removeValue(forKey: key) + } +} diff --git a/bitchatTests/Noise/NoiseCoverageTests.swift b/localPackages/Noise/Tests/NoiseTests/NoiseCoverageTests.swift similarity index 99% rename from bitchatTests/Noise/NoiseCoverageTests.swift rename to localPackages/Noise/Tests/NoiseTests/NoiseCoverageTests.swift index 601b7061..04f4bb27 100644 --- a/bitchatTests/Noise/NoiseCoverageTests.swift +++ b/localPackages/Noise/Tests/NoiseTests/NoiseCoverageTests.swift @@ -2,8 +2,7 @@ import CryptoKit import Foundation import Testing import BitFoundation - -@testable import bitchat +@testable import Noise @Suite("Noise Coverage Tests") struct NoiseCoverageTests { diff --git a/localPackages/Noise/Tests/NoiseTests/NoiseEncryptionTests.swift b/localPackages/Noise/Tests/NoiseTests/NoiseEncryptionTests.swift new file mode 100644 index 00000000..932e11e3 --- /dev/null +++ b/localPackages/Noise/Tests/NoiseTests/NoiseEncryptionTests.swift @@ -0,0 +1,80 @@ +// +// NoiseEncryptionTests.swift +// Noise +// +// Created by Islam on 14/04/2026. +// + +import Testing +import BitFoundation +@testable import Noise + +// MARK: - Integration Tests + +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 } +} diff --git a/bitchatTests/Noise/NoiseProtocolTests.swift b/localPackages/Noise/Tests/NoiseTests/NoiseProtocolTests.swift similarity index 99% rename from bitchatTests/Noise/NoiseProtocolTests.swift rename to localPackages/Noise/Tests/NoiseTests/NoiseProtocolTests.swift index 6ba8dbfa..94b94bad 100644 --- a/bitchatTests/Noise/NoiseProtocolTests.swift +++ b/localPackages/Noise/Tests/NoiseTests/NoiseProtocolTests.swift @@ -10,7 +10,7 @@ import CryptoKit import Foundation import Testing import BitFoundation -@testable import bitchat +@testable import Noise // MARK: - Test Vector Support @@ -613,9 +613,7 @@ struct NoiseProtocolTests { private func loadTestVectors() throws -> [NoiseTestVector] { // Try to load from test bundle - let testBundle = Bundle(for: MockKeychain.self) - guard let url = testBundle.url(forResource: "NoiseTestVectors", withExtension: "json") - else { + guard let url = Bundle.module.url(forResource: "NoiseTestVectors", withExtension: "json") else { throw NSError( domain: "NoiseTests", code: 1, userInfo: [ diff --git a/bitchatTests/Noise/NoiseRateLimiterTests.swift b/localPackages/Noise/Tests/NoiseTests/NoiseRateLimiterTests.swift similarity index 98% rename from bitchatTests/Noise/NoiseRateLimiterTests.swift rename to localPackages/Noise/Tests/NoiseTests/NoiseRateLimiterTests.swift index 623fe22e..74bac77c 100644 --- a/bitchatTests/Noise/NoiseRateLimiterTests.swift +++ b/localPackages/Noise/Tests/NoiseTests/NoiseRateLimiterTests.swift @@ -1,6 +1,6 @@ import XCTest import BitFoundation -@testable import bitchat +@testable import Noise final class NoiseRateLimiterTests: XCTestCase { func test_allowHandshake_blocksAfterPerPeerLimit() { diff --git a/bitchatTests/Noise/NoiseTestVectors.json b/localPackages/Noise/Tests/NoiseTests/NoiseTestVectors.json similarity index 100% rename from bitchatTests/Noise/NoiseTestVectors.json rename to localPackages/Noise/Tests/NoiseTests/NoiseTestVectors.json diff --git a/localPackages/Noise/Tests/NoiseTests/TestHelpers.swift b/localPackages/Noise/Tests/NoiseTests/TestHelpers.swift new file mode 100644 index 00000000..f96a91bf --- /dev/null +++ b/localPackages/Noise/Tests/NoiseTests/TestHelpers.swift @@ -0,0 +1,42 @@ +// +// TestHelpers.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import CryptoKit +import BitFoundation + +// TODO: Combine with the one from the main target +final class TestHelpers { + static func generateRandomData(length: Int) -> Data { + var data = Data(count: length) + _ = data.withUnsafeMutableBytes { bytes in + SecRandomCopyBytes(kSecRandomDefault, length, bytes.baseAddress!) + } + return data + } + + @MainActor + static func waitUntil( + _ condition: @escaping () -> Bool, + timeout: TimeInterval = 5, + pollInterval: TimeInterval = 0.01 + ) async -> Bool { + let start = Date() + while !condition() { + if Date().timeIntervalSince(start) > timeout { + return condition() + } + try? await sleep(pollInterval) + } + return true + } +} + +func sleep(_ seconds: TimeInterval) async throws { + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) +}