mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 07:25:19 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
436ab46aee |
@@ -21,6 +21,8 @@ jobs:
|
|||||||
path: localPackages/BitLogger
|
path: localPackages/BitLogger
|
||||||
- name: BitFoundation
|
- name: BitFoundation
|
||||||
path: localPackages/BitFoundation
|
path: localPackages/BitFoundation
|
||||||
|
- name: Noise
|
||||||
|
path: localPackages/Noise
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
|
|||||||
+3
-2
@@ -17,6 +17,7 @@ let package = Package(
|
|||||||
],
|
],
|
||||||
dependencies:[
|
dependencies:[
|
||||||
.package(path: "localPackages/Arti"),
|
.package(path: "localPackages/Arti"),
|
||||||
|
.package(path: "localPackages/Noise"),
|
||||||
.package(path: "localPackages/BitFoundation"),
|
.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")
|
||||||
@@ -28,6 +29,7 @@ let package = Package(
|
|||||||
.product(name: "P256K", package: "swift-secp256k1"),
|
.product(name: "P256K", package: "swift-secp256k1"),
|
||||||
.product(name: "BitFoundation", package: "BitFoundation"),
|
.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",
|
||||||
@@ -56,8 +58,7 @@ let package = Package(
|
|||||||
"README.md"
|
"README.md"
|
||||||
],
|
],
|
||||||
resources: [
|
resources: [
|
||||||
.process("Localization"),
|
.process("Localization")
|
||||||
.process("Noise")
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
Generated
+20
@@ -10,6 +10,8 @@
|
|||||||
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 */; };
|
A6BCF9482F80953E001CF9B9 /* BitFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = A6BCF9472F80953E001CF9B9 /* BitFoundation */; };
|
||||||
A6BCF94A2F809550001CF9B9 /* BitFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = A6BCF9492F809550001CF9B9 /* 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 */; };
|
||||||
@@ -156,6 +158,7 @@
|
|||||||
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 */,
|
A6BCF94A2F809550001CF9B9 /* BitFoundation in Frameworks */,
|
||||||
@@ -165,6 +168,7 @@
|
|||||||
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 */,
|
A6BCF9482F80953E001CF9B9 /* BitFoundation in Frameworks */,
|
||||||
@@ -228,6 +232,7 @@
|
|||||||
A6E3E5712E7703760032EA8A /* BitLogger */,
|
A6E3E5712E7703760032EA8A /* BitLogger */,
|
||||||
A6E3EA802E7706A80032EA8A /* Tor */,
|
A6E3EA802E7706A80032EA8A /* Tor */,
|
||||||
A6BCF9492F809550001CF9B9 /* BitFoundation */,
|
A6BCF9492F809550001CF9B9 /* BitFoundation */,
|
||||||
|
A63163B72F80CB2D00B8B128 /* Noise */,
|
||||||
);
|
);
|
||||||
productName = bitchat_macOS;
|
productName = bitchat_macOS;
|
||||||
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
|
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
|
||||||
@@ -309,6 +314,7 @@
|
|||||||
A6E3E56F2E77036A0032EA8A /* BitLogger */,
|
A6E3E56F2E77036A0032EA8A /* BitLogger */,
|
||||||
A6E3EA7E2E7706720032EA8A /* Tor */,
|
A6E3EA7E2E7706720032EA8A /* Tor */,
|
||||||
A6BCF9472F80953E001CF9B9 /* BitFoundation */,
|
A6BCF9472F80953E001CF9B9 /* BitFoundation */,
|
||||||
|
A63163B52F80CB2500B8B128 /* Noise */,
|
||||||
);
|
);
|
||||||
productName = bitchat_iOS;
|
productName = bitchat_iOS;
|
||||||
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
|
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
|
||||||
@@ -351,6 +357,7 @@
|
|||||||
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
|
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
|
||||||
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */,
|
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */,
|
||||||
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */,
|
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */,
|
||||||
|
A63163B42F80CB2500B8B128 /* XCLocalSwiftPackageReference "localPackages/Noise" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 90;
|
preferredProjectObjectVersion = 90;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
@@ -916,6 +923,10 @@
|
|||||||
/* 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" */ = {
|
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */ = {
|
||||||
isa = XCLocalSwiftPackageReference;
|
isa = XCLocalSwiftPackageReference;
|
||||||
relativePath = localPackages/BitFoundation;
|
relativePath = localPackages/BitFoundation;
|
||||||
@@ -947,6 +958,15 @@
|
|||||||
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 */ = {
|
A6BCF9472F80953E001CF9B9 /* BitFoundation */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = BitFoundation;
|
productName = BitFoundation;
|
||||||
|
|||||||
@@ -84,6 +84,7 @@
|
|||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
|
import Noise
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
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 BitFoundation // to avoid unnecessary public's
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import Testing
|
import Testing
|
||||||
|
import Noise
|
||||||
@testable import BitFoundation // to avoid unnecessary public's
|
@testable import BitFoundation // to avoid unnecessary public's
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
import Noise
|
||||||
@testable import BitFoundation // to avoid unnecessary public's
|
@testable import BitFoundation // to avoid unnecessary public's
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -0,0 +1,772 @@
|
|||||||
|
//
|
||||||
|
// NoiseEncryptionService.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
///
|
||||||
|
/// # 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
|
||||||
|
}
|
||||||
+1
-1
@@ -924,7 +924,7 @@ extension NoisePattern {
|
|||||||
|
|
||||||
// MARK: - Errors
|
// MARK: - Errors
|
||||||
|
|
||||||
enum NoiseError: Error {
|
public enum NoiseError: Error {
|
||||||
case uninitializedCipher
|
case uninitializedCipher
|
||||||
case invalidCiphertext
|
case invalidCiphertext
|
||||||
case handshakeComplete
|
case handshakeComplete
|
||||||
+7
-5
@@ -10,7 +10,7 @@ import BitLogger
|
|||||||
import BitFoundation
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
final class NoiseRateLimiter {
|
public final class NoiseRateLimiter {
|
||||||
private var handshakeTimestamps: [PeerID: [Date]] = [:]
|
private var handshakeTimestamps: [PeerID: [Date]] = [:]
|
||||||
private var messageTimestamps: [PeerID: [Date]] = [:]
|
private var messageTimestamps: [PeerID: [Date]] = [:]
|
||||||
|
|
||||||
@@ -19,8 +19,10 @@ final class NoiseRateLimiter {
|
|||||||
private var globalMessageTimestamps: [Date] = []
|
private var globalMessageTimestamps: [Date] = []
|
||||||
|
|
||||||
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
|
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) {
|
return queue.sync(flags: .barrier) {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let oneMinuteAgo = now.addingTimeInterval(-60)
|
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) {
|
return queue.sync(flags: .barrier) {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let oneSecondAgo = now.addingTimeInterval(-1)
|
let oneSecondAgo = now.addingTimeInterval(-1)
|
||||||
@@ -85,7 +87,7 @@ final class NoiseRateLimiter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func resetAll() {
|
public func resetAll() {
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
self.handshakeTimestamps.removeAll()
|
self.handshakeTimestamps.removeAll()
|
||||||
self.messageTimestamps.removeAll()
|
self.messageTimestamps.removeAll()
|
||||||
+5
-5
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum NoiseSecurityConstants {
|
public enum NoiseSecurityConstants {
|
||||||
// Maximum message size to prevent memory exhaustion
|
// Maximum message size to prevent memory exhaustion
|
||||||
static let maxMessageSize = 65535 // 64KB as per Noise spec
|
static let maxMessageSize = 65535 // 64KB as per Noise spec
|
||||||
|
|
||||||
@@ -28,10 +28,10 @@ enum NoiseSecurityConstants {
|
|||||||
static let maxSessionsPerPeer = 3
|
static let maxSessionsPerPeer = 3
|
||||||
|
|
||||||
// Rate limiting
|
// Rate limiting
|
||||||
static let maxHandshakesPerMinute = 10
|
public static let maxHandshakesPerMinute = 10
|
||||||
static let maxMessagesPerSecond = 100
|
public static let maxMessagesPerSecond = 100
|
||||||
|
|
||||||
// Global rate limiting (across all peers)
|
// Global rate limiting (across all peers)
|
||||||
static let maxGlobalHandshakesPerMinute = 30
|
public static let maxGlobalHandshakesPerMinute = 30
|
||||||
static let maxGlobalMessagesPerSecond = 500
|
public static let maxGlobalMessagesPerSecond = 500
|
||||||
}
|
}
|
||||||
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum NoiseSecurityError: Error {
|
public enum NoiseSecurityError: Error {
|
||||||
case sessionExpired
|
case sessionExpired
|
||||||
case sessionExhausted
|
case sessionExhausted
|
||||||
case messageTooLarge
|
case messageTooLarge
|
||||||
+3
-3
@@ -8,15 +8,15 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct NoiseSecurityValidator {
|
public struct NoiseSecurityValidator {
|
||||||
|
|
||||||
/// Validate message size
|
/// Validate message size
|
||||||
static func validateMessageSize(_ data: Data) -> Bool {
|
public static func validateMessageSize(_ data: Data) -> Bool {
|
||||||
return data.count <= NoiseSecurityConstants.maxMessageSize
|
return data.count <= NoiseSecurityConstants.maxMessageSize
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate handshake message size
|
/// Validate handshake message size
|
||||||
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
public static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
||||||
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
|
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+4
-4
@@ -11,7 +11,7 @@ import Foundation
|
|||||||
import CryptoKit
|
import CryptoKit
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
|
|
||||||
class NoiseSession {
|
public class NoiseSession {
|
||||||
let peerID: PeerID
|
let peerID: PeerID
|
||||||
let role: NoiseRole
|
let role: NoiseRole
|
||||||
private let keychain: KeychainManagerProtocol
|
private let keychain: KeychainManagerProtocol
|
||||||
@@ -182,7 +182,7 @@ class NoiseSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func isEstablished() -> Bool {
|
public func isEstablished() -> Bool {
|
||||||
return sessionQueue.sync {
|
return sessionQueue.sync {
|
||||||
if case .established = state {
|
if case .established = state {
|
||||||
return true
|
return true
|
||||||
@@ -217,8 +217,8 @@ class NoiseSession {
|
|||||||
sentHandshakeMessages.removeAll()
|
sentHandshakeMessages.removeAll()
|
||||||
|
|
||||||
// Clear handshake hash
|
// Clear handshake hash
|
||||||
if var hash = handshakeHash {
|
if handshakeHash != nil {
|
||||||
keychain.secureClear(&hash)
|
keychain.secureClear(&handshakeHash!)
|
||||||
}
|
}
|
||||||
handshakeHash = nil
|
handshakeHash = nil
|
||||||
|
|
||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
enum NoiseSessionError: Error, Equatable {
|
public enum NoiseSessionError: Error, Equatable {
|
||||||
case invalidState
|
case invalidState
|
||||||
case notEstablished
|
case notEstablished
|
||||||
case sessionNotFound
|
case sessionNotFound
|
||||||
+13
-13
@@ -11,7 +11,7 @@ import CryptoKit
|
|||||||
import Foundation
|
import Foundation
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
|
|
||||||
final class NoiseSessionManager {
|
public final class NoiseSessionManager {
|
||||||
private var sessions: [PeerID: NoiseSession] = [:]
|
private var sessions: [PeerID: NoiseSession] = [:]
|
||||||
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
|
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
|
||||||
private let keychain: KeychainManagerProtocol
|
private let keychain: KeychainManagerProtocol
|
||||||
@@ -19,10 +19,10 @@ final class NoiseSessionManager {
|
|||||||
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey) -> Void)?
|
public var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey) -> Void)?
|
||||||
var onSessionFailed: ((PeerID, Error) -> 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.localStaticKey = localStaticKey
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
self.sessionFactory = { peerID, role in
|
self.sessionFactory = { peerID, role in
|
||||||
@@ -49,13 +49,13 @@ final class NoiseSessionManager {
|
|||||||
|
|
||||||
// MARK: - Session Management
|
// MARK: - Session Management
|
||||||
|
|
||||||
func getSession(for peerID: PeerID) -> NoiseSession? {
|
public func getSession(for peerID: PeerID) -> NoiseSession? {
|
||||||
return managerQueue.sync {
|
return managerQueue.sync {
|
||||||
return sessions[peerID]
|
return sessions[peerID]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeSession(for peerID: PeerID) {
|
public func removeSession(for peerID: PeerID) {
|
||||||
managerQueue.sync(flags: .barrier) {
|
managerQueue.sync(flags: .barrier) {
|
||||||
if let session = sessions.removeValue(forKey: peerID) {
|
if let session = sessions.removeValue(forKey: peerID) {
|
||||||
session.reset() // Clear sensitive data before removing
|
session.reset() // Clear sensitive data before removing
|
||||||
@@ -63,7 +63,7 @@ final class NoiseSessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeAllSessions() {
|
public func removeAllSessions() {
|
||||||
managerQueue.sync(flags: .barrier) {
|
managerQueue.sync(flags: .barrier) {
|
||||||
for (_, session) in sessions {
|
for (_, session) in sessions {
|
||||||
session.reset()
|
session.reset()
|
||||||
@@ -74,7 +74,7 @@ final class NoiseSessionManager {
|
|||||||
|
|
||||||
// MARK: - Handshake Helpers
|
// MARK: - Handshake Helpers
|
||||||
|
|
||||||
func initiateHandshake(with peerID: PeerID) throws -> Data {
|
public func initiateHandshake(with peerID: PeerID) throws -> Data {
|
||||||
return try managerQueue.sync(flags: .barrier) {
|
return try managerQueue.sync(flags: .barrier) {
|
||||||
// Check if we already have an established session
|
// Check if we already have an established session
|
||||||
if let existingSession = sessions[peerID], existingSession.isEstablished() {
|
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
|
// Process everything within the synchronized block to prevent race conditions
|
||||||
return try managerQueue.sync(flags: .barrier) {
|
return try managerQueue.sync(flags: .barrier) {
|
||||||
var shouldCreateNew = false
|
var shouldCreateNew = false
|
||||||
@@ -173,7 +173,7 @@ final class NoiseSessionManager {
|
|||||||
|
|
||||||
// MARK: - Encryption/Decryption
|
// 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 {
|
guard let session = getSession(for: peerID) else {
|
||||||
throw NoiseSessionError.sessionNotFound
|
throw NoiseSessionError.sessionNotFound
|
||||||
}
|
}
|
||||||
@@ -181,7 +181,7 @@ final class NoiseSessionManager {
|
|||||||
return try session.encrypt(plaintext)
|
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 {
|
guard let session = getSession(for: peerID) else {
|
||||||
throw NoiseSessionError.sessionNotFound
|
throw NoiseSessionError.sessionNotFound
|
||||||
}
|
}
|
||||||
@@ -191,13 +191,13 @@ final class NoiseSessionManager {
|
|||||||
|
|
||||||
// MARK: - Key Management
|
// 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()
|
return getSession(for: peerID)?.getRemoteStaticPublicKey()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Session Rekeying
|
// MARK: - Session Rekeying
|
||||||
|
|
||||||
func getSessionsNeedingRekey() -> [(peerID: PeerID, needsRekey: Bool)] {
|
public func getSessionsNeedingRekey() -> [(peerID: PeerID, needsRekey: Bool)] {
|
||||||
return managerQueue.sync {
|
return managerQueue.sync {
|
||||||
var needingRekey: [(peerID: PeerID, needsRekey: Bool)] = []
|
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
|
// Remove old session
|
||||||
removeSession(for: peerID)
|
removeSession(for: peerID)
|
||||||
|
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
//
|
||||||
|
// MockKeychain.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-2
@@ -2,8 +2,7 @@ import CryptoKit
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
|
@testable import Noise
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
@Suite("Noise Coverage Tests")
|
@Suite("Noise Coverage Tests")
|
||||||
struct NoiseCoverageTests {
|
struct NoiseCoverageTests {
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
+2
-4
@@ -10,7 +10,7 @@ import CryptoKit
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
@testable import bitchat
|
@testable import Noise
|
||||||
|
|
||||||
// MARK: - Test Vector Support
|
// MARK: - Test Vector Support
|
||||||
|
|
||||||
@@ -613,9 +613,7 @@ struct NoiseProtocolTests {
|
|||||||
|
|
||||||
private func loadTestVectors() throws -> [NoiseTestVector] {
|
private func loadTestVectors() throws -> [NoiseTestVector] {
|
||||||
// Try to load from test bundle
|
// Try to load from test bundle
|
||||||
let testBundle = Bundle(for: MockKeychain.self)
|
guard let url = Bundle.module.url(forResource: "NoiseTestVectors", withExtension: "json") else {
|
||||||
guard let url = testBundle.url(forResource: "NoiseTestVectors", withExtension: "json")
|
|
||||||
else {
|
|
||||||
throw NSError(
|
throw NSError(
|
||||||
domain: "NoiseTests", code: 1,
|
domain: "NoiseTests", code: 1,
|
||||||
userInfo: [
|
userInfo: [
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
@testable import bitchat
|
@testable import Noise
|
||||||
|
|
||||||
final class NoiseRateLimiterTests: XCTestCase {
|
final class NoiseRateLimiterTests: XCTestCase {
|
||||||
func test_allowHandshake_blocksAfterPerPeerLimit() {
|
func test_allowHandshake_blocksAfterPerPeerLimit() {
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
//
|
||||||
|
// TestHelpers.swift
|
||||||
|
// bitchatTests
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user