Extract Noise into a dedicated module

This commit is contained in:
islam
2026-04-18 13:02:16 +01:00
parent c60eff2c11
commit 436ab46aee
26 changed files with 1201 additions and 42 deletions
+2
View File
@@ -21,6 +21,8 @@ jobs:
path: localPackages/BitLogger
- name: BitFoundation
path: localPackages/BitFoundation
- name: Noise
path: localPackages/Noise
steps:
- name: Checkout code
+3 -2
View File
@@ -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")
]
)
]
+20
View File
@@ -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;
@@ -84,6 +84,7 @@
import BitLogger
import BitFoundation
import Noise
import Foundation
import CryptoKit
@@ -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
@@ -9,6 +9,7 @@
import Foundation
import CryptoKit
import Testing
import Noise
@testable import BitFoundation // to avoid unnecessary public's
@testable import bitchat
@@ -8,6 +8,7 @@
import Foundation
import CryptoKit
import Noise
@testable import BitFoundation // to avoid unnecessary public's
@testable import bitchat
+38
View File
@@ -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
}
@@ -924,7 +924,7 @@ extension NoisePattern {
// MARK: - Errors
enum NoiseError: Error {
public enum NoiseError: Error {
case uninitializedCipher
case invalidCiphertext
case handshakeComplete
@@ -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()
@@ -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
}
@@ -8,7 +8,7 @@
import Foundation
enum NoiseSecurityError: Error {
public enum NoiseSecurityError: Error {
case sessionExpired
case sessionExhausted
case messageTooLarge
@@ -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
}
}
@@ -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
@@ -6,7 +6,7 @@
// For more information, see <https://unlicense.org>
//
enum NoiseSessionError: Error, Equatable {
public enum NoiseSessionError: Error, Equatable {
case invalidState
case notEstablished
case sessionNotFound
@@ -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)
@@ -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)
}
}
@@ -2,8 +2,7 @@ import CryptoKit
import Foundation
import Testing
import BitFoundation
@testable import bitchat
@testable import Noise
@Suite("Noise Coverage Tests")
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 }
}
@@ -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: [
@@ -1,6 +1,6 @@
import XCTest
import BitFoundation
@testable import bitchat
@testable import Noise
final class NoiseRateLimiterTests: XCTestCase {
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))
}