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
+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
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,98 @@
//
// NoiseRateLimiter.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitLogger
import BitFoundation
import Foundation
public final class NoiseRateLimiter {
private var handshakeTimestamps: [PeerID: [Date]] = [:]
private var messageTimestamps: [PeerID: [Date]] = [:]
// Global rate limiting
private var globalHandshakeTimestamps: [Date] = []
private var globalMessageTimestamps: [Date] = []
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
public init() {}
public func allowHandshake(from peerID: PeerID) -> Bool {
return queue.sync(flags: .barrier) {
let now = Date()
let oneMinuteAgo = now.addingTimeInterval(-60)
// Check global rate limit first
globalHandshakeTimestamps = globalHandshakeTimestamps.filter { $0 > oneMinuteAgo }
if globalHandshakeTimestamps.count >= NoiseSecurityConstants.maxGlobalHandshakesPerMinute {
SecureLogger.warning("Global handshake rate limit exceeded: \(globalHandshakeTimestamps.count)/\(NoiseSecurityConstants.maxGlobalHandshakesPerMinute) per minute", category: .security)
return false
}
// Check per-peer rate limit
var timestamps = handshakeTimestamps[peerID] ?? []
timestamps = timestamps.filter { $0 > oneMinuteAgo }
if timestamps.count >= NoiseSecurityConstants.maxHandshakesPerMinute {
SecureLogger.warning("Per-peer handshake rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxHandshakesPerMinute) per minute", category: .security)
return false
}
// Record new handshake
timestamps.append(now)
handshakeTimestamps[peerID] = timestamps
globalHandshakeTimestamps.append(now)
return true
}
}
public func allowMessage(from peerID: PeerID) -> Bool {
return queue.sync(flags: .barrier) {
let now = Date()
let oneSecondAgo = now.addingTimeInterval(-1)
// Check global rate limit first
globalMessageTimestamps = globalMessageTimestamps.filter { $0 > oneSecondAgo }
if globalMessageTimestamps.count >= NoiseSecurityConstants.maxGlobalMessagesPerSecond {
SecureLogger.warning("Global message rate limit exceeded: \(globalMessageTimestamps.count)/\(NoiseSecurityConstants.maxGlobalMessagesPerSecond) per second", category: .security)
return false
}
// Check per-peer rate limit
var timestamps = messageTimestamps[peerID] ?? []
timestamps = timestamps.filter { $0 > oneSecondAgo }
if timestamps.count >= NoiseSecurityConstants.maxMessagesPerSecond {
SecureLogger.warning("Per-peer message rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxMessagesPerSecond) per second", category: .security)
return false
}
// Record new message
timestamps.append(now)
messageTimestamps[peerID] = timestamps
globalMessageTimestamps.append(now)
return true
}
}
func reset(for peerID: PeerID) {
queue.async(flags: .barrier) {
self.handshakeTimestamps.removeValue(forKey: peerID)
self.messageTimestamps.removeValue(forKey: peerID)
}
}
public func resetAll() {
queue.async(flags: .barrier) {
self.handshakeTimestamps.removeAll()
self.messageTimestamps.removeAll()
self.globalHandshakeTimestamps.removeAll()
self.globalMessageTimestamps.removeAll()
}
}
}
@@ -0,0 +1,37 @@
//
// NoiseSecurityConstants.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
public enum NoiseSecurityConstants {
// Maximum message size to prevent memory exhaustion
static let maxMessageSize = 65535 // 64KB as per Noise spec
// Maximum handshake message size
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
// Session timeout - sessions older than this should be renegotiated
static let sessionTimeout: TimeInterval = 86400 // 24 hours
// Maximum number of messages before rekey (2^64 - 1 is the nonce limit)
static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages
// Handshake timeout - abandon incomplete handshakes
static let handshakeTimeout: TimeInterval = 60 // 1 minute
// Maximum concurrent sessions per peer
static let maxSessionsPerPeer = 3
// Rate limiting
public static let maxHandshakesPerMinute = 10
public static let maxMessagesPerSecond = 100
// Global rate limiting (across all peers)
public static let maxGlobalHandshakesPerMinute = 30
public static let maxGlobalMessagesPerSecond = 500
}
@@ -0,0 +1,18 @@
//
// NoiseSecurityError.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
public enum NoiseSecurityError: Error {
case sessionExpired
case sessionExhausted
case messageTooLarge
case invalidPeerID
case rateLimitExceeded
case handshakeTimeout
}
@@ -0,0 +1,22 @@
//
// NoiseSecurityValidator.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
public struct NoiseSecurityValidator {
/// Validate message size
public static func validateMessageSize(_ data: Data) -> Bool {
return data.count <= NoiseSecurityConstants.maxMessageSize
}
/// Validate handshake message size
public static func validateHandshakeMessageSize(_ data: Data) -> Bool {
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
}
}
@@ -0,0 +1,230 @@
//
// NoiseSession.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitLogger
import Foundation
import CryptoKit
import BitFoundation
public class NoiseSession {
let peerID: PeerID
let role: NoiseRole
private let keychain: KeychainManagerProtocol
private var state: NoiseSessionState = .uninitialized
private var handshakeState: NoiseHandshakeState?
private var sendCipher: NoiseCipherState?
private var receiveCipher: NoiseCipherState?
// Keys
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
private var remoteStaticPublicKey: Curve25519.KeyAgreement.PublicKey?
// Handshake messages for retransmission
private var sentHandshakeMessages: [Data] = []
private var handshakeHash: Data?
// Thread safety
private let sessionQueue = DispatchQueue(label: "chat.bitchat.noise.session", attributes: .concurrent)
init(
peerID: PeerID,
role: NoiseRole,
keychain: KeychainManagerProtocol,
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil
) {
self.peerID = peerID
self.role = role
self.keychain = keychain
self.localStaticKey = localStaticKey
self.remoteStaticPublicKey = remoteStaticKey
}
// MARK: - Handshake
func startHandshake() throws -> Data {
return try sessionQueue.sync(flags: .barrier) {
guard case .uninitialized = state else {
throw NoiseSessionError.invalidState
}
// For XX pattern, we don't need remote static key upfront
handshakeState = NoiseHandshakeState(
role: role,
pattern: .XX,
keychain: keychain,
localStaticKey: localStaticKey,
remoteStaticKey: nil
)
state = .handshaking
// Only initiator writes the first message
if role == .initiator {
let message = try handshakeState!.writeMessage()
sentHandshakeMessages.append(message)
return message
} else {
// Responder doesn't send first message in XX pattern
return Data()
}
}
}
func processHandshakeMessage(_ message: Data) throws -> Data? {
return try sessionQueue.sync(flags: .barrier) {
SecureLogger.debug("NoiseSession[\(peerID)]: Processing handshake message, current state: \(state), role: \(role)")
// Initialize handshake state if needed (for responders)
if state == .uninitialized && role == .responder {
handshakeState = NoiseHandshakeState(
role: role,
pattern: .XX,
keychain: keychain,
localStaticKey: localStaticKey,
remoteStaticKey: nil
)
state = .handshaking
SecureLogger.debug("NoiseSession[\(peerID)]: Initialized handshake state for responder")
}
guard case .handshaking = state, let handshake = handshakeState else {
throw NoiseSessionError.invalidState
}
// Process incoming message
_ = try handshake.readMessage(message)
SecureLogger.debug("NoiseSession[\(peerID)]: Read handshake message, checking if complete")
// Check if handshake is complete
if handshake.isHandshakeComplete() {
// Get transport ciphers and handshake hash (hash captured before split clears state)
let (send, receive, hash) = try handshake.getTransportCiphers(useExtractedNonce: true)
sendCipher = send
receiveCipher = receive
// Store remote static key
remoteStaticPublicKey = handshake.getRemoteStaticPublicKey()
// Store handshake hash for channel binding
handshakeHash = hash
state = .established
handshakeState = nil // Clear handshake state
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established")
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
return nil
} else {
// Generate response
let response = try handshake.writeMessage()
sentHandshakeMessages.append(response)
SecureLogger.debug("NoiseSession[\(peerID)]: Generated handshake response of size \(response.count)")
// Check if handshake is complete after writing
if handshake.isHandshakeComplete() {
// Get transport ciphers and handshake hash (hash captured before split clears state)
let (send, receive, hash) = try handshake.getTransportCiphers(useExtractedNonce: true)
sendCipher = send
receiveCipher = receive
// Store remote static key
remoteStaticPublicKey = handshake.getRemoteStaticPublicKey()
// Store handshake hash for channel binding
handshakeHash = hash
state = .established
handshakeState = nil // Clear handshake state
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established")
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
}
return response
}
}
}
// MARK: - Transport
func encrypt(_ plaintext: Data) throws -> Data {
return try sessionQueue.sync(flags: .barrier) {
guard case .established = state, let cipher = sendCipher else {
throw NoiseSessionError.notEstablished
}
return try cipher.encrypt(plaintext: plaintext)
}
}
func decrypt(_ ciphertext: Data) throws -> Data {
return try sessionQueue.sync(flags: .barrier) {
guard case .established = state, let cipher = receiveCipher else {
throw NoiseSessionError.notEstablished
}
return try cipher.decrypt(ciphertext: ciphertext)
}
}
// MARK: - State Management
func getState() -> NoiseSessionState {
return sessionQueue.sync {
return state
}
}
public func isEstablished() -> Bool {
return sessionQueue.sync {
if case .established = state {
return true
}
return false
}
}
func getRemoteStaticPublicKey() -> Curve25519.KeyAgreement.PublicKey? {
return sessionQueue.sync {
return remoteStaticPublicKey
}
}
func reset() {
sessionQueue.sync(flags: .barrier) {
let wasEstablished = state == .established
state = .uninitialized
handshakeState = nil
// Clear sensitive cipher states
sendCipher?.clearSensitiveData()
receiveCipher?.clearSensitiveData()
sendCipher = nil
receiveCipher = nil
// Clear sent handshake messages
for i in 0..<sentHandshakeMessages.count {
var message = sentHandshakeMessages[i]
keychain.secureClear(&message)
}
sentHandshakeMessages.removeAll()
// Clear handshake hash
if handshakeHash != nil {
keychain.secureClear(&handshakeHash!)
}
handshakeHash = nil
if wasEstablished {
SecureLogger.info(.sessionExpired(peerID: peerID.id))
}
}
}
}
@@ -0,0 +1,14 @@
//
// NoiseSessionError.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
public enum NoiseSessionError: Error, Equatable {
case invalidState
case notEstablished
case sessionNotFound
case alreadyEstablished
}
@@ -0,0 +1,223 @@
//
// NoiseSessionManager.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitLogger
import CryptoKit
import Foundation
import BitFoundation
public final class NoiseSessionManager {
private var sessions: [PeerID: NoiseSession] = [:]
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
private let keychain: KeychainManagerProtocol
private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
// Callbacks
public var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey) -> Void)?
var onSessionFailed: ((PeerID, Error) -> Void)?
public init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
self.localStaticKey = localStaticKey
self.keychain = keychain
self.sessionFactory = { peerID, role in
SecureNoiseSession(
peerID: peerID,
role: role,
keychain: keychain,
localStaticKey: localStaticKey
)
}
}
#if DEBUG
init(
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
keychain: KeychainManagerProtocol,
sessionFactory: @escaping (PeerID, NoiseRole) -> NoiseSession
) {
self.localStaticKey = localStaticKey
self.keychain = keychain
self.sessionFactory = sessionFactory
}
#endif
// MARK: - Session Management
public func getSession(for peerID: PeerID) -> NoiseSession? {
return managerQueue.sync {
return sessions[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
}
}
}
public func removeAllSessions() {
managerQueue.sync(flags: .barrier) {
for (_, session) in sessions {
session.reset()
}
sessions.removeAll()
}
}
// MARK: - Handshake Helpers
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() {
// Session already established, don't recreate
throw NoiseSessionError.alreadyEstablished
}
// Remove any existing non-established session
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
_ = sessions.removeValue(forKey: peerID)
}
// Create new initiator session
let session = sessionFactory(peerID, .initiator)
sessions[peerID] = session
do {
let handshakeData = try session.startHandshake()
return handshakeData
} catch {
// Clean up failed session
_ = sessions.removeValue(forKey: peerID)
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
throw error
}
}
}
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
var existingSession: NoiseSession? = nil
if let existing = sessions[peerID] {
// If we have an established session, the peer must have cleared their session
// for a good reason (e.g., decryption failure, restart, etc.)
// We should accept the new handshake to re-establish encryption
if existing.isEstablished() {
SecureLogger.info("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session", category: .session)
_ = sessions.removeValue(forKey: peerID)
shouldCreateNew = true
} else {
// If we're in the middle of a handshake and receive a new initiation,
// reset and start fresh (the other side may have restarted)
if existing.getState() == .handshaking && message.count == 32 {
_ = sessions.removeValue(forKey: peerID)
shouldCreateNew = true
} else {
existingSession = existing
}
}
} else {
shouldCreateNew = true
}
// Get or create session
let session: NoiseSession
if shouldCreateNew {
let newSession = sessionFactory(peerID, .responder)
sessions[peerID] = newSession
session = newSession
} else {
session = existingSession!
}
// Process the handshake message within the synchronized block
do {
let response = try session.processHandshakeMessage(message)
// Check if session is established after processing
if session.isEstablished() {
if let remoteKey = session.getRemoteStaticPublicKey() {
// Schedule callback outside the synchronized block to prevent deadlock
DispatchQueue.global().async { [weak self] in
self?.onSessionEstablished?(peerID, remoteKey)
}
}
}
return response
} catch {
// Reset the session on handshake failure so next attempt can start fresh
_ = sessions.removeValue(forKey: peerID)
// Schedule callback outside the synchronized block to prevent deadlock
DispatchQueue.global().async { [weak self] in
self?.onSessionFailed?(peerID, error)
}
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
throw error
}
}
}
// MARK: - Encryption/Decryption
public func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data {
guard let session = getSession(for: peerID) else {
throw NoiseSessionError.sessionNotFound
}
return try session.encrypt(plaintext)
}
public func decrypt(_ ciphertext: Data, from peerID: PeerID) throws -> Data {
guard let session = getSession(for: peerID) else {
throw NoiseSessionError.sessionNotFound
}
return try session.decrypt(ciphertext)
}
// MARK: - Key Management
public func getRemoteStaticKey(for peerID: PeerID) -> Curve25519.KeyAgreement.PublicKey? {
return getSession(for: peerID)?.getRemoteStaticPublicKey()
}
// MARK: - Session Rekeying
public func getSessionsNeedingRekey() -> [(peerID: PeerID, needsRekey: Bool)] {
return managerQueue.sync {
var needingRekey: [(peerID: PeerID, needsRekey: Bool)] = []
for (peerID, session) in sessions {
if let secureSession = session as? SecureNoiseSession,
secureSession.isEstablished(),
secureSession.needsRenegotiation() {
needingRekey.append((peerID: peerID, needsRekey: true))
}
}
return needingRekey
}
}
public func initiateRekey(for peerID: PeerID) throws {
// Remove old session
removeSession(for: peerID)
// Initiate new handshake
_ = try initiateHandshake(with: peerID)
}
}
@@ -0,0 +1,13 @@
//
// NoiseSessionState.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
enum NoiseSessionState: Equatable {
case uninitialized
case handshaking
case established
}
@@ -0,0 +1,85 @@
//
// SecureNoiseSession.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
final class SecureNoiseSession: NoiseSession {
private(set) var messageCount: UInt64 = 0
private var sessionStartTime = Date()
private(set) var lastActivityTime = Date()
override func encrypt(_ plaintext: Data) throws -> Data {
// Check session age
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
throw NoiseSecurityError.sessionExpired
}
// Check message count
if messageCount >= NoiseSecurityConstants.maxMessagesPerSession {
throw NoiseSecurityError.sessionExhausted
}
// Validate message size
guard NoiseSecurityValidator.validateMessageSize(plaintext) else {
throw NoiseSecurityError.messageTooLarge
}
let encrypted = try super.encrypt(plaintext)
messageCount += 1
lastActivityTime = Date()
return encrypted
}
override func decrypt(_ ciphertext: Data) throws -> Data {
// Check session age
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
throw NoiseSecurityError.sessionExpired
}
// Validate message size
guard NoiseSecurityValidator.validateMessageSize(ciphertext) else {
throw NoiseSecurityError.messageTooLarge
}
let decrypted = try super.decrypt(ciphertext)
lastActivityTime = Date()
return decrypted
}
func needsRenegotiation() -> Bool {
// Check if we've used more than 90% of message limit
let messageThreshold = UInt64(Double(NoiseSecurityConstants.maxMessagesPerSession) * 0.9)
if messageCount >= messageThreshold {
return true
}
// Check if last activity was more than 30 minutes ago
if Date().timeIntervalSince(lastActivityTime) > NoiseSecurityConstants.sessionTimeout {
return true
}
return false
}
// MARK: - Testing Support
#if DEBUG
func setLastActivityTimeForTesting(_ date: Date) {
lastActivityTime = date
}
func setMessageCountForTesting(_ count: UInt64) {
messageCount = count
}
func setSessionStartTimeForTesting(_ date: Date) {
sessionStartTime = date
}
#endif
}
@@ -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)
}
}
@@ -0,0 +1,867 @@
import CryptoKit
import Foundation
import Testing
import BitFoundation
@testable import Noise
@Suite("Noise Coverage Tests")
struct NoiseCoverageTests {
private let keychain = MockKeychain()
private let aliceStaticKey = Curve25519.KeyAgreement.PrivateKey()
private let bobStaticKey = Curve25519.KeyAgreement.PrivateKey()
private let charlieStaticKey = Curve25519.KeyAgreement.PrivateKey()
private let alicePeerID = PeerID(str: "0011223344556677")
private let bobPeerID = PeerID(str: "8899aabbccddeeff")
private let charliePeerID = PeerID(str: "fedcba9876543210")
@Test("Protocol metadata and handshake patterns expose expected values")
func protocolMetadataAndHandshakePatterns() {
let ikName = NoiseProtocolName(pattern: NoisePattern.IK.patternName)
#expect(ikName.pattern == "IK")
#expect(ikName.dh == "25519")
#expect(ikName.cipher == "ChaChaPoly")
#expect(ikName.hash == "SHA256")
#expect(ikName.fullName == "Noise_IK_25519_ChaChaPoly_SHA256")
#expect(NoisePattern.XX.patternName == "XX")
#expect(NoisePattern.IK.patternName == "IK")
#expect(NoisePattern.NK.patternName == "NK")
let ikPatterns = NoisePattern.IK.messagePatterns
#expect(ikPatterns.count == 2)
#expect(ikPatterns[0] == [.e, .es, .s, .ss])
#expect(ikPatterns[1] == [.e, .ee, .se])
let nkPatterns = NoisePattern.NK.messagePatterns
#expect(nkPatterns.count == 2)
#expect(nkPatterns[0] == [.e, .es])
#expect(nkPatterns[1] == [.e, .ee])
}
@Test("Symmetric state supports long protocol names and mixKeyAndHash")
func symmetricStateLongNameAndMixKeyAndHash() {
let longName = String(repeating: "NoiseProtocol_", count: 3)
let symmetricState = NoiseSymmetricState(protocolName: longName)
let initialHash = symmetricState.getHandshakeHash()
#expect(initialHash.count == 32)
#expect(!symmetricState.hasCipherKey())
symmetricState.mixKeyAndHash(Data("input-key-material".utf8))
#expect(symmetricState.hasCipherKey())
#expect(symmetricState.getHandshakeHash() != initialHash)
}
@Test("Cipher state rejects duplicate and stale extracted nonces")
func cipherStateRejectsDuplicateAndStaleNonces() throws {
let key = SymmetricKey(size: .bits256)
let receiver = NoiseCipherState(key: key, useExtractedNonce: true)
let initialPayload = try makeExtractedNoncePayload(
key: key,
nonce: 0,
plaintext: Data("nonce-0".utf8)
)
let initialPlaintext = try receiver.decrypt(ciphertext: initialPayload)
#expect(initialPlaintext == Data("nonce-0".utf8))
#expect(throws: (any Error).self) {
try receiver.decrypt(ciphertext: initialPayload)
}
for nonce in 1...1024 {
let payload = try makeExtractedNoncePayload(
key: key,
nonce: UInt64(nonce),
plaintext: Data("nonce-\(nonce)".utf8)
)
let plaintext = try receiver.decrypt(ciphertext: payload)
#expect(plaintext == Data("nonce-\(nonce)".utf8))
}
#expect(throws: (any Error).self) {
try receiver.decrypt(ciphertext: initialPayload)
}
}
@Test("Cipher state handles large nonce jumps and associated-data mismatches")
func cipherStateHandlesLargeJumpsAndAADMismatch() throws {
let key = SymmetricKey(size: .bits256)
let extractedReceiver = NoiseCipherState(key: key, useExtractedNonce: true)
let jumped = try makeExtractedNoncePayload(
key: key,
nonce: 1500,
plaintext: Data("future".utf8)
)
let slightlyOlder = try makeExtractedNoncePayload(
key: key,
nonce: 1499,
plaintext: Data("older".utf8)
)
let tooOld = try makeExtractedNoncePayload(
key: key,
nonce: 100,
plaintext: Data("ancient".utf8)
)
#expect(try extractedReceiver.decrypt(ciphertext: jumped) == Data("future".utf8))
#expect(try extractedReceiver.decrypt(ciphertext: slightlyOlder) == Data("older".utf8))
#expect(throws: (any Error).self) {
try extractedReceiver.decrypt(ciphertext: tooOld)
}
let sender = NoiseCipherState(key: key)
let receiver = NoiseCipherState(key: key)
let plaintext = Data("associated-data".utf8)
let aad = Data("good-aad".utf8)
let ciphertext = try sender.encrypt(plaintext: plaintext, associatedData: aad)
#expect(throws: (any Error).self) {
try receiver.decrypt(ciphertext: ciphertext, associatedData: Data("bad-aad".utf8))
}
#expect(try receiver.decrypt(ciphertext: ciphertext, associatedData: aad) == plaintext)
#expect(throws: (any Error).self) {
try receiver.decrypt(ciphertext: Data(repeating: 0xAA, count: 15))
}
}
@Test("Cipher state covers nonce guard rails and extracted payload bounds")
func cipherStateCoversNonceGuardRailsAndExtractedPayloadBounds() throws {
let uninitializedCipher = NoiseCipherState()
#expect(throws: NoiseError.uninitializedCipher) {
try uninitializedCipher.encrypt(plaintext: Data("missing-key".utf8))
}
#expect(throws: NoiseError.uninitializedCipher) {
try uninitializedCipher.decrypt(ciphertext: Data(repeating: 0x00, count: 16))
}
#expect(try uninitializedCipher.extractNonceFromCiphertextPayloadForTesting(Data([0x00, 0x01, 0x02])) == nil)
let key = SymmetricKey(size: .bits256)
let highNonceCipher = NoiseCipherState(key: key)
highNonceCipher.setNonceForTesting(1_000_000_001)
#expect(throws: Never.self) {
_ = try highNonceCipher.encrypt(plaintext: Data("high-nonce".utf8))
}
let exhaustedCipher = NoiseCipherState(key: key)
exhaustedCipher.setNonceForTesting(UInt64(UInt32.max))
#expect(throws: NoiseError.nonceExceeded) {
try exhaustedCipher.encrypt(plaintext: Data("nonce-limit".utf8))
}
}
@Test("Handshake validation rejects malformed keys and messages")
func handshakeValidationRejectsMalformedInputs() throws {
let responder = NoiseHandshakeState(
role: .responder,
pattern: .XX,
keychain: keychain,
localStaticKey: bobStaticKey
)
#expect(throws: (any Error).self) {
try responder.readMessage(Data(repeating: 0x00, count: 31))
}
let invalidKeys = [
Data(),
Data(repeating: 0x00, count: 32),
Data([0x01] + Array(repeating: 0x00, count: 31)),
Data(repeating: 0xFF, count: 32),
]
for invalidKey in invalidKeys {
#expect(throws: (any Error).self) {
_ = try NoiseHandshakeState.validatePublicKey(invalidKey)
}
}
let valid = aliceStaticKey.publicKey.rawRepresentation
let roundTripped = try NoiseHandshakeState.validatePublicKey(valid)
#expect(roundTripped.rawRepresentation == valid)
let initiator = NoiseHandshakeState(
role: .initiator,
pattern: .XX,
keychain: keychain,
localStaticKey: aliceStaticKey
)
let responderForTamper = NoiseHandshakeState(
role: .responder,
pattern: .XX,
keychain: keychain,
localStaticKey: bobStaticKey
)
let message1 = try initiator.writeMessage()
_ = try responderForTamper.readMessage(message1)
var message2 = try responderForTamper.writeMessage()
message2[40] ^= 0x01
#expect(throws: (any Error).self) {
try initiator.readMessage(message2)
}
}
@Test("Handshake readers reject invalid ephemeral and truncated static payloads")
func handshakeReadersRejectInvalidEphemeralAndTruncatedStaticPayloads() throws {
let invalidEphemeralResponder = NoiseHandshakeState(
role: .responder,
pattern: .XX,
keychain: keychain,
localStaticKey: bobStaticKey
)
#expect(throws: NoiseError.invalidMessage) {
try invalidEphemeralResponder.readMessage(Data(repeating: 0x00, count: 32))
}
let truncatedStaticInitiator = NoiseHandshakeState(
role: .initiator,
pattern: .XX,
keychain: keychain,
localStaticKey: aliceStaticKey
)
_ = try truncatedStaticInitiator.writeMessage()
let responderEphemeralOnly = Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation
#expect(throws: NoiseError.invalidMessage) {
try truncatedStaticInitiator.readMessage(responderEphemeralOnly)
}
}
@Test("IK handshake completes and supports transport messages")
func ikHandshakeCompletesAndSupportsTransportMessages() throws {
let initiator = NoiseHandshakeState(
role: .initiator,
pattern: .IK,
keychain: keychain,
localStaticKey: aliceStaticKey,
remoteStaticKey: bobStaticKey.publicKey
)
let responder = NoiseHandshakeState(
role: .responder,
pattern: .IK,
keychain: keychain,
localStaticKey: bobStaticKey
)
let outboundPayload = Data("ik-outbound".utf8)
let returnPayload = Data("ik-return".utf8)
let message1 = try initiator.writeMessage(payload: outboundPayload)
#expect(try responder.readMessage(message1) == outboundPayload)
let message2 = try responder.writeMessage(payload: returnPayload)
#expect(try initiator.readMessage(message2) == returnPayload)
#expect(initiator.isHandshakeComplete())
#expect(responder.isHandshakeComplete())
let (initiatorSend, initiatorReceive, initiatorHash) = try initiator.getTransportCiphers(
useExtractedNonce: true
)
let (responderSend, responderReceive, responderHash) = try responder.getTransportCiphers(
useExtractedNonce: true
)
#expect(initiatorHash == responderHash)
let clientCiphertext = try initiatorSend.encrypt(plaintext: Data("ik-transport".utf8))
#expect(try responderReceive.decrypt(ciphertext: clientCiphertext) == Data("ik-transport".utf8))
let serverCiphertext = try responderSend.encrypt(plaintext: Data("ik-response".utf8))
#expect(try initiatorReceive.decrypt(ciphertext: serverCiphertext) == Data("ik-response".utf8))
}
@Test("NK handshake requires a responder static key and supports transport messages")
func nkHandshakeRequiresStaticAndSupportsTransportMessages() throws {
let missingStaticInitiator = NoiseHandshakeState(
role: .initiator,
pattern: .NK,
keychain: keychain,
localStaticKey: aliceStaticKey
)
#expect(throws: (any Error).self) {
try missingStaticInitiator.writeMessage()
}
let initiator = NoiseHandshakeState(
role: .initiator,
pattern: .NK,
keychain: keychain,
localStaticKey: aliceStaticKey,
remoteStaticKey: bobStaticKey.publicKey
)
let responder = NoiseHandshakeState(
role: .responder,
pattern: .NK,
keychain: keychain,
localStaticKey: bobStaticKey
)
let outboundPayload = Data("nk-outbound".utf8)
let returnPayload = Data("nk-return".utf8)
let message1 = try initiator.writeMessage(payload: outboundPayload)
#expect(try responder.readMessage(message1) == outboundPayload)
let message2 = try responder.writeMessage(payload: returnPayload)
#expect(try initiator.readMessage(message2) == returnPayload)
#expect(initiator.isHandshakeComplete())
#expect(responder.isHandshakeComplete())
let (initiatorSend, initiatorReceive, initiatorHash) = try initiator.getTransportCiphers(
useExtractedNonce: true
)
let (responderSend, responderReceive, responderHash) = try responder.getTransportCiphers(
useExtractedNonce: true
)
#expect(initiatorHash == responderHash)
let clientCiphertext = try initiatorSend.encrypt(plaintext: Data("nk-transport".utf8))
#expect(try responderReceive.decrypt(ciphertext: clientCiphertext) == Data("nk-transport".utf8))
let serverCiphertext = try responderSend.encrypt(plaintext: Data("nk-response".utf8))
#expect(try initiatorReceive.decrypt(ciphertext: serverCiphertext) == Data("nk-response".utf8))
}
@Test("Responder-side NK writes require peer ephemeral input")
func responderWritesRequirePeerEphemeralInput() {
let nkResponder = NoiseHandshakeState(
role: .responder,
pattern: .NK,
keychain: keychain,
localStaticKey: bobStaticKey
)
#expect(throws: NoiseError.missingKeys) {
try nkResponder.writeMessage()
}
}
@Test("Direct DH helpers reject missing keys across all patterns")
func directDHHelpersRejectMissingKeysAcrossAllPatterns() throws {
let eeState = NoiseHandshakeState(
role: .initiator,
pattern: .XX,
keychain: keychain,
localStaticKey: aliceStaticKey
)
#expect(throws: NoiseError.missingKeys) {
try eeState.performDHOperationForTesting(.ee)
}
let esInitiator = NoiseHandshakeState(
role: .initiator,
pattern: .XX,
keychain: keychain,
localStaticKey: aliceStaticKey
)
#expect(throws: NoiseError.missingKeys) {
try esInitiator.performDHOperationForTesting(.es)
}
let esResponder = NoiseHandshakeState(
role: .responder,
pattern: .XX,
keychain: keychain,
localStaticKey: nil
)
#expect(throws: NoiseError.missingKeys) {
try esResponder.performDHOperationForTesting(.es)
}
let seInitiator = NoiseHandshakeState(
role: .initiator,
pattern: .XX,
keychain: keychain,
localStaticKey: nil
)
#expect(throws: NoiseError.missingKeys) {
try seInitiator.performDHOperationForTesting(.se)
}
let seResponder = NoiseHandshakeState(
role: .responder,
pattern: .XX,
keychain: keychain,
localStaticKey: bobStaticKey
)
#expect(throws: NoiseError.missingKeys) {
try seResponder.performDHOperationForTesting(.se)
}
let ssState = NoiseHandshakeState(
role: .initiator,
pattern: .XX,
keychain: keychain,
localStaticKey: nil
)
#expect(throws: NoiseError.missingKeys) {
try ssState.performDHOperationForTesting(.ss)
}
#expect(throws: Never.self) {
try eeState.performDHOperationForTesting(.e)
try eeState.performDHOperationForTesting(.s)
}
}
@Test("Prepared handshake writers cover remaining missing-key branches")
func preparedHandshakeWritersCoverRemainingMissingKeyBranches() {
let eeResponder = NoiseHandshakeState(
role: .responder,
pattern: .NK,
keychain: keychain,
localStaticKey: bobStaticKey
)
eeResponder.setCurrentPatternForTesting(1)
#expect(throws: NoiseError.missingKeys) {
try eeResponder.writeMessage()
}
let seInitiator = NoiseHandshakeState(
role: .initiator,
pattern: .XX,
keychain: keychain,
localStaticKey: aliceStaticKey
)
seInitiator.setCurrentPatternForTesting(2)
#expect(throws: NoiseError.missingKeys) {
try seInitiator.writeMessage()
}
let seResponder = NoiseHandshakeState(
role: .responder,
pattern: .IK,
keychain: keychain,
localStaticKey: bobStaticKey
)
seResponder.setCurrentPatternForTesting(1)
seResponder.setRemoteEphemeralPublicKeyForTesting(Curve25519.KeyAgreement.PrivateKey().publicKey)
#expect(throws: NoiseError.missingKeys) {
try seResponder.writeMessage()
}
}
@Test("Completed handshakes reject additional reads and writes")
func completedHandshakesRejectAdditionalReadsAndWrites() throws {
let initiator = NoiseHandshakeState(
role: .initiator,
pattern: .IK,
keychain: keychain,
localStaticKey: aliceStaticKey,
remoteStaticKey: bobStaticKey.publicKey
)
let responder = NoiseHandshakeState(
role: .responder,
pattern: .IK,
keychain: keychain,
localStaticKey: bobStaticKey
)
let message1 = try initiator.writeMessage(payload: Data("first".utf8))
_ = try responder.readMessage(message1)
let message2 = try responder.writeMessage(payload: Data("second".utf8))
_ = try initiator.readMessage(message2)
#expect(throws: NoiseError.handshakeComplete) {
try initiator.writeMessage()
}
#expect(throws: NoiseError.handshakeComplete) {
try responder.readMessage(message1)
}
}
@Test("XX final message requires a local static key")
func xxFinalMessageRequiresLocalStaticKey() throws {
let initiator = NoiseHandshakeState(
role: .initiator,
pattern: .XX,
keychain: keychain,
localStaticKey: nil
)
let responder = NoiseHandshakeState(
role: .responder,
pattern: .XX,
keychain: keychain,
localStaticKey: bobStaticKey
)
let message1 = try initiator.writeMessage()
_ = try responder.readMessage(message1)
let message2 = try responder.writeMessage()
_ = try initiator.readMessage(message2)
#expect(throws: (any Error).self) {
try initiator.writeMessage()
}
}
@Test("Responder start handshake is empty and transport ciphers require completion")
func responderStartHandshakeAndIncompleteTransportCiphers() throws {
let responderSession = NoiseSession(
peerID: bobPeerID,
role: .responder,
keychain: keychain,
localStaticKey: bobStaticKey
)
let incompleteHandshake = NoiseHandshakeState(
role: .initiator,
pattern: .XX,
keychain: keychain,
localStaticKey: aliceStaticKey
)
#expect(try responderSession.startHandshake().isEmpty)
#expect(responderSession.getState() == .handshaking)
#expect(throws: (any Error).self) {
_ = try incompleteHandshake.getTransportCiphers(useExtractedNonce: true)
}
}
@Test("Session manager callbacks establish and failed handshakes clean up state")
func sessionManagerCallbacksAndFailureCleanup() async throws {
let establishedRecorder = SessionCallbackRecorder()
let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain)
let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain)
aliceManager.onSessionEstablished = establishedRecorder.recordEstablished(peerID:remoteKey:)
bobManager.onSessionEstablished = establishedRecorder.recordEstablished(peerID:remoteKey:)
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
let didEstablish = await TestHelpers.waitUntil(
{ establishedRecorder.establishedCount == 2 },
timeout: 0.5
)
#expect(didEstablish)
#expect(establishedRecorder.establishedPeerIDs.contains(alicePeerID))
#expect(establishedRecorder.establishedPeerIDs.contains(bobPeerID))
let failureRecorder = SessionCallbackRecorder()
let failingManager = NoiseSessionManager(localStaticKey: charlieStaticKey, keychain: keychain)
failingManager.onSessionFailed = failureRecorder.recordFailure(peerID:error:)
#expect(throws: (any Error).self) {
try failingManager.handleIncomingHandshake(
from: charliePeerID,
message: Data(repeating: 0x00, count: 31)
)
}
let didFail = await TestHelpers.waitUntil(
{ failureRecorder.failureCount == 1 },
timeout: 0.5
)
#expect(didFail)
#expect(failingManager.getSession(for: charliePeerID) == nil)
}
@Test("Session manager cleans up initiator sessions after start-handshake failures")
func sessionManagerCleansUpInitiatorSessionsAfterStartHandshakeFailures() {
let manager = NoiseSessionManager(
localStaticKey: aliceStaticKey,
keychain: keychain,
sessionFactory: { peerID, role in
FailingNoiseSession(
peerID: peerID,
role: role,
keychain: self.keychain,
localStaticKey: self.aliceStaticKey
)
}
)
#expect(throws: FailingNoiseSession.Error.synthetic) {
try manager.initiateHandshake(with: alicePeerID)
}
#expect(manager.getSession(for: alicePeerID) == nil)
}
@Test("Session manager rekeys established sessions and replaces partial handshakes")
func sessionManagerRekeysAndReplacesSessions() throws {
let manager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain)
#expect(throws: NoiseSessionError.sessionNotFound) {
try manager.encrypt(Data("missing".utf8), for: alicePeerID)
}
#expect(throws: NoiseSessionError.sessionNotFound) {
try manager.decrypt(Data("missing".utf8), from: alicePeerID)
}
let initialHandshake = try manager.initiateHandshake(with: alicePeerID)
#expect(!initialHandshake.isEmpty)
let firstSession = try #require(manager.getSession(for: alicePeerID))
let restartedHandshake = try manager.initiateHandshake(with: alicePeerID)
let restartedSession = try #require(manager.getSession(for: alicePeerID))
#expect(!restartedHandshake.isEmpty)
#expect(restartedSession !== firstSession)
let restartedInitiator = NoiseSession(
peerID: alicePeerID,
role: .initiator,
keychain: keychain,
localStaticKey: bobStaticKey
)
let replacementMessage = try restartedInitiator.startHandshake()
let replacementResponse = try manager.handleIncomingHandshake(
from: alicePeerID,
message: replacementMessage
)
let replacementSession = try #require(manager.getSession(for: alicePeerID))
#expect(replacementResponse != nil)
#expect(replacementSession !== restartedSession)
let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain)
let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain)
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
let establishedSession = try #require(
aliceManager.getSession(for: alicePeerID) as? SecureNoiseSession
)
establishedSession.setMessageCountForTesting(
UInt64(Double(NoiseSecurityConstants.maxMessagesPerSession) * 0.9)
)
let sessionsNeedingRekey = aliceManager.getSessionsNeedingRekey()
#expect(sessionsNeedingRekey.contains { $0.peerID == alicePeerID && $0.needsRekey })
#expect(throws: NoiseSessionError.alreadyEstablished) {
try aliceManager.initiateHandshake(with: alicePeerID)
}
try aliceManager.initiateRekey(for: alicePeerID)
let rekeyedSession = try #require(aliceManager.getSession(for: alicePeerID))
#expect(rekeyedSession !== establishedSession)
#expect(rekeyedSession.getState() == .handshaking)
}
@Test("Secure noise sessions enforce limits and renegotiation thresholds")
func secureNoiseSessionsEnforceLimitsAndThresholds() throws {
let initiator = SecureNoiseSession(
peerID: alicePeerID,
role: .initiator,
keychain: keychain,
localStaticKey: aliceStaticKey
)
let responder = SecureNoiseSession(
peerID: bobPeerID,
role: .responder,
keychain: keychain,
localStaticKey: bobStaticKey
)
try establishSessions(initiator: initiator, responder: responder)
responder.setMessageCountForTesting(0)
responder.setLastActivityTimeForTesting(Date())
#expect(!responder.needsRenegotiation())
responder.setMessageCountForTesting(
UInt64(Double(NoiseSecurityConstants.maxMessagesPerSession) * 0.9)
)
#expect(responder.needsRenegotiation())
responder.setMessageCountForTesting(0)
responder.setLastActivityTimeForTesting(
Date().addingTimeInterval(-(NoiseSecurityConstants.sessionTimeout + 1))
)
#expect(responder.needsRenegotiation())
initiator.setMessageCountForTesting(NoiseSecurityConstants.maxMessagesPerSession)
#expect(throws: (any Error).self) {
try initiator.encrypt(Data("exhausted".utf8))
}
initiator.setMessageCountForTesting(0)
#expect(throws: (any Error).self) {
try initiator.encrypt(Data(repeating: 0xAB, count: NoiseSecurityConstants.maxMessageSize + 1))
}
responder.setLastActivityTimeForTesting(Date())
#expect(throws: (any Error).self) {
try responder.decrypt(
Data(repeating: 0xCD, count: NoiseSecurityConstants.maxMessageSize + 1)
)
}
let transportCiphertext = try initiator.encrypt(Data("secure-session".utf8))
#expect(try responder.decrypt(transportCiphertext) == Data("secure-session".utf8))
}
@Test("Secure noise sessions expire based on session start time")
func secureNoiseSessionsExpireBasedOnSessionStartTime() throws {
let initiator = SecureNoiseSession(
peerID: alicePeerID,
role: .initiator,
keychain: keychain,
localStaticKey: aliceStaticKey
)
let responder = SecureNoiseSession(
peerID: bobPeerID,
role: .responder,
keychain: keychain,
localStaticKey: bobStaticKey
)
try establishSessions(initiator: initiator, responder: responder)
initiator.setSessionStartTimeForTesting(
Date().addingTimeInterval(-(NoiseSecurityConstants.sessionTimeout + 1))
)
#expect(throws: (any Error).self) {
try initiator.encrypt(Data("expired".utf8))
}
responder.setSessionStartTimeForTesting(
Date().addingTimeInterval(-(NoiseSecurityConstants.sessionTimeout + 1))
)
#expect(throws: (any Error).self) {
try responder.decrypt(Data())
}
}
@Test("Rate limiter handles global message caps and per-peer resets")
func rateLimiterGlobalMessageCapAndReset() async throws {
let globalLimiter = NoiseRateLimiter()
for index in 0..<NoiseSecurityConstants.maxGlobalMessagesPerSecond {
#expect(globalLimiter.allowMessage(from: PeerID(str: "peer-\(index)")))
}
#expect(!globalLimiter.allowMessage(from: charliePeerID))
let peerLimiter = NoiseRateLimiter()
for _ in 0..<NoiseSecurityConstants.maxMessagesPerSecond {
#expect(peerLimiter.allowMessage(from: alicePeerID))
}
#expect(!peerLimiter.allowMessage(from: alicePeerID))
peerLimiter.reset(for: alicePeerID)
try await sleep(0.05)
#expect(peerLimiter.allowMessage(from: alicePeerID))
}
@Test("Cipher state decrypts high extracted nonces and rejects truncated extracted payloads")
func cipherStateDecryptsHighExtractedNoncesAndRejectsTruncatedPayloads() throws {
let key = SymmetricKey(size: .bits256)
let receiver = NoiseCipherState(key: key, useExtractedNonce: true)
let highNoncePayload = try makeExtractedNoncePayload(
key: key,
nonce: 1_000_000_001,
plaintext: Data("high-nonce".utf8)
)
#expect(try receiver.decrypt(ciphertext: highNoncePayload) == Data("high-nonce".utf8))
#expect(throws: NoiseError.invalidCiphertext) {
try receiver.decrypt(ciphertext: extractedNoncePrefix(7))
}
}
private func establishSessions(initiator: NoiseSession, responder: NoiseSession) throws {
let message1 = try initiator.startHandshake()
let response2 = try responder.processHandshakeMessage(message1)
let message2 = try #require(response2)
let response3 = try initiator.processHandshakeMessage(message2)
let message3 = try #require(response3)
let final = try responder.processHandshakeMessage(message3)
#expect(final == nil)
}
private func establishManagerSessions(
aliceManager: NoiseSessionManager,
bobManager: NoiseSessionManager
) throws {
let message1 = try aliceManager.initiateHandshake(with: alicePeerID)
let response2 = try bobManager.handleIncomingHandshake(from: bobPeerID, message: message1)
let message2 = try #require(response2)
let response3 = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: message2)
let message3 = try #require(response3)
let final = try bobManager.handleIncomingHandshake(from: bobPeerID, message: message3)
#expect(final == nil)
}
private func makeExtractedNoncePayload(
key: SymmetricKey,
nonce: UInt64,
plaintext: Data,
associatedData: Data = Data()
) throws -> Data {
var fullNonce = Data(count: 12)
withUnsafeBytes(of: nonce.littleEndian) { bytes in
fullNonce.replaceSubrange(4..<12, with: bytes)
}
let sealedBox = try ChaChaPoly.seal(
plaintext,
using: key,
nonce: ChaChaPoly.Nonce(data: fullNonce),
authenticating: associatedData
)
return extractedNoncePrefix(nonce) + sealedBox.ciphertext + sealedBox.tag
}
private func extractedNoncePrefix(_ nonce: UInt64) -> Data {
withUnsafeBytes(of: nonce.bigEndian) { bytes in
Data(bytes.suffix(4))
}
}
}
private final class SessionCallbackRecorder: @unchecked Sendable {
private let lock = NSLock()
private var establishedEntries: [(PeerID, Data)] = []
private var failureEntries: [(PeerID, String)] = []
var establishedCount: Int {
lock.lock()
defer { lock.unlock() }
return establishedEntries.count
}
var failureCount: Int {
lock.lock()
defer { lock.unlock() }
return failureEntries.count
}
var establishedPeerIDs: [PeerID] {
lock.lock()
defer { lock.unlock() }
return establishedEntries.map(\.0)
}
func recordEstablished(peerID: PeerID, remoteKey: Curve25519.KeyAgreement.PublicKey) {
lock.lock()
establishedEntries.append((peerID, remoteKey.rawRepresentation))
lock.unlock()
}
func recordFailure(peerID: PeerID, error: Error) {
lock.lock()
failureEntries.append((peerID, String(describing: error)))
lock.unlock()
}
}
private final class FailingNoiseSession: NoiseSession {
enum Error: Swift.Error {
case synthetic
}
override func startHandshake() throws -> Data {
throw Error.synthetic
}
}
@@ -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 }
}
@@ -0,0 +1,945 @@
//
// NoiseProtocolTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import CryptoKit
import Foundation
import Testing
import BitFoundation
@testable import Noise
// MARK: - Test Vector Support
struct NoiseTestVector: Codable {
let protocol_name: String
let init_prologue: String
let init_static: String
let init_ephemeral: String
let init_psks: [String]?
let resp_prologue: String
let resp_static: String
let resp_ephemeral: String
let resp_psks: [String]?
let handshake_hash: String?
let messages: [TestMessage]
struct TestMessage: Codable {
let payload: String
let ciphertext: String
}
}
extension Data {
init?(hex: String) {
let cleaned = hex.replacingOccurrences(of: " ", with: "")
guard cleaned.count % 2 == 0 else { return nil }
var data = Data(capacity: cleaned.count / 2)
var index = cleaned.startIndex
while index < cleaned.endIndex {
let nextIndex = cleaned.index(index, offsetBy: 2)
guard let byte = UInt8(cleaned[index..<nextIndex], radix: 16) else { return nil }
data.append(byte)
index = nextIndex
}
self = data
}
func hexString() -> String {
map { String(format: "%02x", $0) }.joined()
}
}
struct NoiseProtocolTests {
private let aliceKey = Curve25519.KeyAgreement.PrivateKey()
private let bobKey = Curve25519.KeyAgreement.PrivateKey()
private let mockKeychain = MockKeychain()
private let alicePeerID = PeerID(str: UUID().uuidString)
private let bobPeerID = PeerID(str: UUID().uuidString)
private let aliceSession: NoiseSession
private let bobSession: NoiseSession
init() {
aliceSession = NoiseSession(
peerID: alicePeerID,
role: .initiator,
keychain: mockKeychain,
localStaticKey: aliceKey
)
bobSession = NoiseSession(
peerID: bobPeerID,
role: .responder,
keychain: mockKeychain,
localStaticKey: bobKey
)
}
// MARK: - Basic Handshake Tests
@Test func xxPatternHandshake() throws {
// Alice starts handshake (message 1)
let message1 = try aliceSession.startHandshake()
#expect(!message1.isEmpty)
#expect(aliceSession.getState() == .handshaking)
// Bob processes message 1 and creates message 2
let message2 = try bobSession.processHandshakeMessage(message1)
#expect(message2 != nil)
#expect(!message2!.isEmpty)
#expect(bobSession.getState() == .handshaking)
// Alice processes message 2 and creates message 3
let message3 = try aliceSession.processHandshakeMessage(message2!)
#expect(message3 != nil)
#expect(!message3!.isEmpty)
#expect(aliceSession.getState() == .established)
// Bob processes message 3 and completes handshake
let finalMessage = try bobSession.processHandshakeMessage(message3!)
#expect(finalMessage == nil) // No more messages needed
#expect(bobSession.getState() == .established)
// Verify both sessions are established
#expect(aliceSession.isEstablished())
#expect(bobSession.isEstablished())
// Verify they have each other's static keys
#expect(
aliceSession.getRemoteStaticPublicKey()?.rawRepresentation
== bobKey.publicKey.rawRepresentation)
#expect(
bobSession.getRemoteStaticPublicKey()?.rawRepresentation
== aliceKey.publicKey.rawRepresentation)
}
@Test func handshakeStateValidation() throws {
// Cannot process message before starting handshake
#expect(throws: NoiseSessionError.invalidState) {
try aliceSession.processHandshakeMessage(Data())
}
// Start handshake
_ = try aliceSession.startHandshake()
// Cannot start handshake twice
#expect(throws: NoiseSessionError.invalidState) {
try aliceSession.startHandshake()
}
}
// MARK: - Encryption/Decryption Tests
@Test func basicEncryptionDecryption() throws {
try performHandshake(initiator: aliceSession, responder: bobSession)
let plaintext = "Hello, Bob!".data(using: .utf8)!
// Alice encrypts
let ciphertext = try aliceSession.encrypt(plaintext)
#expect(ciphertext != plaintext)
#expect(ciphertext.count > plaintext.count) // Should have overhead
// Bob decrypts
let decrypted = try bobSession.decrypt(ciphertext)
#expect(decrypted == plaintext)
}
@Test func bidirectionalEncryption() throws {
try performHandshake(initiator: aliceSession, responder: bobSession)
// Alice -> Bob
let aliceMessage = "Hello from Alice".data(using: .utf8)!
let aliceCiphertext = try aliceSession.encrypt(aliceMessage)
let bobReceived = try bobSession.decrypt(aliceCiphertext)
#expect(bobReceived == aliceMessage)
// Bob -> Alice
let bobMessage = "Hello from Bob".data(using: .utf8)!
let bobCiphertext = try bobSession.encrypt(bobMessage)
let aliceReceived = try aliceSession.decrypt(bobCiphertext)
#expect(aliceReceived == bobMessage)
}
@Test func largeMessageEncryption() throws {
try performHandshake(initiator: aliceSession, responder: bobSession)
// Create a large message
let largeMessage = TestHelpers.generateRandomData(length: 100_000)
// Encrypt and decrypt
let ciphertext = try aliceSession.encrypt(largeMessage)
let decrypted = try bobSession.decrypt(ciphertext)
#expect(decrypted == largeMessage)
}
@Test func encryptionBeforeHandshake() {
let plaintext = "test".data(using: .utf8)!
#expect(throws: NoiseSessionError.notEstablished) {
try aliceSession.encrypt(plaintext)
}
#expect(throws: NoiseSessionError.notEstablished) {
try aliceSession.decrypt(plaintext)
}
}
// MARK: - Session Manager Tests
@Test func sessionManagerBasicOperations() throws {
let manager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
#expect(manager.getSession(for: alicePeerID) == nil)
_ = try manager.initiateHandshake(with: alicePeerID)
#expect(manager.getSession(for: alicePeerID) != nil)
// Get session
let retrieved = manager.getSession(for: alicePeerID)
#expect(retrieved != nil)
// Remove session
manager.removeSession(for: alicePeerID)
#expect(manager.getSession(for: alicePeerID) == nil)
}
@Test func sessionManagerHandshakeInitiation() throws {
let manager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
// Initiate handshake
let handshakeData = try manager.initiateHandshake(with: alicePeerID)
#expect(!handshakeData.isEmpty)
// Session should exist
let session = manager.getSession(for: alicePeerID)
#expect(session != nil)
#expect(session?.getState() == .handshaking)
}
@Test func sessionManagerIncomingHandshake() throws {
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
// Alice initiates
let message1 = try aliceManager.initiateHandshake(with: alicePeerID)
// Bob responds
let message2 = try bobManager.handleIncomingHandshake(from: bobPeerID, message: message1)
#expect(message2 != nil)
// Continue handshake
let message3 = try aliceManager.handleIncomingHandshake(
from: alicePeerID, message: message2!)
#expect(message3 != nil)
// Complete handshake
let finalMessage = try bobManager.handleIncomingHandshake(
from: bobPeerID, message: message3!)
#expect(finalMessage == nil)
// Both should have established sessions
#expect(aliceManager.getSession(for: alicePeerID)?.isEstablished() == true)
#expect(bobManager.getSession(for: bobPeerID)?.isEstablished() == true)
}
@Test func sessionManagerEncryptionDecryption() throws {
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
// Establish sessions
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
// Encrypt with manager
let plaintext = "Test message".data(using: .utf8)!
let ciphertext = try aliceManager.encrypt(plaintext, for: alicePeerID)
// Decrypt with manager
let decrypted = try bobManager.decrypt(ciphertext, from: bobPeerID)
#expect(decrypted == plaintext)
}
// MARK: - Security Tests
@Test func tamperedCiphertextDetection() throws {
try performHandshake(initiator: aliceSession, responder: bobSession)
let plaintext = "Secret message".data(using: .utf8)!
var ciphertext = try aliceSession.encrypt(plaintext)
// Tamper with ciphertext
ciphertext[ciphertext.count / 2] ^= 0xFF
// Decryption should fail
if #available(macOS 14.4, iOS 17.4, *) {
#expect(throws: CryptoKitError.authenticationFailure) {
try bobSession.decrypt(ciphertext)
}
} else {
#expect(throws: (any Error).self) {
try bobSession.decrypt(ciphertext)
}
}
}
@Test func replayPrevention() throws {
try performHandshake(initiator: aliceSession, responder: bobSession)
let plaintext = "Test message".data(using: .utf8)!
let ciphertext = try aliceSession.encrypt(plaintext)
// First decryption should succeed
_ = try bobSession.decrypt(ciphertext)
// Replaying the same ciphertext should fail
#expect(throws: NoiseError.replayDetected) {
try bobSession.decrypt(ciphertext)
}
}
@Test func sessionIsolation() throws {
// Create two separate session pairs
let aliceSession1 = NoiseSession(
peerID: PeerID(str: "peer1"), role: .initiator, keychain: mockKeychain,
localStaticKey: aliceKey)
let bobSession1 = NoiseSession(
peerID: PeerID(str: "alice1"), role: .responder, keychain: mockKeychain,
localStaticKey: bobKey)
let aliceSession2 = NoiseSession(
peerID: PeerID(str: "peer2"), role: .initiator, keychain: mockKeychain,
localStaticKey: aliceKey)
let bobSession2 = NoiseSession(
peerID: PeerID(str: "alice2"), role: .responder, keychain: mockKeychain,
localStaticKey: bobKey)
// Establish both pairs
try performHandshake(initiator: aliceSession1, responder: bobSession1)
try performHandshake(initiator: aliceSession2, responder: bobSession2)
// Encrypt with session 1
let plaintext = "Secret".data(using: .utf8)!
let ciphertext1 = try aliceSession1.encrypt(plaintext)
// Should not be able to decrypt with session 2
if #available(macOS 14.4, iOS 17.4, *) {
#expect(throws: CryptoKitError.authenticationFailure) {
try bobSession2.decrypt(ciphertext1)
}
} else {
#expect(throws: (any Error).self) {
try bobSession2.decrypt(ciphertext1)
}
}
// But should work with correct session
let decrypted = try bobSession1.decrypt(ciphertext1)
#expect(decrypted == plaintext)
}
// MARK: - Session Recovery Tests
@Test func peerRestartDetection() throws {
// Establish initial sessions
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
// Exchange some messages to establish nonce state
let message1 = try aliceManager.encrypt("Hello".data(using: .utf8)!, for: alicePeerID)
_ = try bobManager.decrypt(message1, from: bobPeerID)
let message2 = try bobManager.encrypt("World".data(using: .utf8)!, for: bobPeerID)
_ = try aliceManager.decrypt(message2, from: alicePeerID)
// Simulate Bob restart by creating new manager with same key
let bobManagerRestarted = NoiseSessionManager(
localStaticKey: bobKey, keychain: mockKeychain)
// Bob initiates new handshake after restart
let newHandshake1 = try bobManagerRestarted.initiateHandshake(with: bobPeerID)
// Alice should accept the new handshake (clearing old session)
let newHandshake2 = try aliceManager.handleIncomingHandshake(
from: alicePeerID, message: newHandshake1)
#expect(newHandshake2 != nil)
// Complete the new handshake
let newHandshake3 = try bobManagerRestarted.handleIncomingHandshake(
from: bobPeerID, message: newHandshake2!)
#expect(newHandshake3 != nil)
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake3!)
// Should be able to exchange messages with new sessions
let testMessage = "After restart".data(using: .utf8)!
let encrypted = try bobManagerRestarted.encrypt(testMessage, for: bobPeerID)
let decrypted = try aliceManager.decrypt(encrypted, from: alicePeerID)
#expect(decrypted == testMessage)
}
@Test func nonceDesynchronizationRecovery() throws {
// Create two sessions
let aliceSession = NoiseSession(
peerID: alicePeerID, role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey)
let bobSession = NoiseSession(
peerID: bobPeerID, role: .responder, keychain: mockKeychain, localStaticKey: bobKey)
// Establish sessions
try performHandshake(initiator: aliceSession, responder: bobSession)
// Exchange messages to advance nonces
for i in 0..<5 {
let msg = try aliceSession.encrypt("Message \(i)".data(using: .utf8)!)
_ = try bobSession.decrypt(msg)
}
// Simulate desynchronization by encrypting but not decrypting
for i in 0..<3 {
_ = try aliceSession.encrypt("Lost message \(i)".data(using: .utf8)!)
}
// With per-packet nonce carried, decryption should not throw here
let desyncMessage = try aliceSession.encrypt("This now succeeds".data(using: .utf8)!)
#expect(throws: Never.self) {
try bobSession.decrypt(desyncMessage)
}
}
@Test func concurrentEncryption() async throws {
// Test thread safety of encryption operations
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
let messageCount = 100
try await confirmation("All messages encrypted and decrypted", expectedCount: messageCount)
{ completion in
var encryptedMessages: [Int: Data] = [:]
// Encrypt messages sequentially to avoid nonce races in manager
for i in 0..<messageCount {
let plaintext = "Concurrent message \(i)".data(using: .utf8)!
let encrypted = try aliceManager.encrypt(plaintext, for: alicePeerID)
encryptedMessages[i] = encrypted
}
// Decrypt messages sequentially to avoid triggering anti-replay with reordering
for i in 0..<messageCount {
do {
guard let encrypted = encryptedMessages[i] else {
Issue.record("Missing encrypted message \(i)")
return
}
let decrypted = try bobManager.decrypt(encrypted, from: bobPeerID)
let expected = "Concurrent message \(i)".data(using: .utf8)!
#expect(decrypted == expected)
completion()
} catch {
Issue.record("Decryption failed for message \(i): \(error)")
}
}
}
}
@Test func sessionStaleDetection() throws {
// Test that sessions are properly marked as stale
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
// Get the session and check it needs renegotiation based on age
let sessions = aliceManager.getSessionsNeedingRekey()
// New session should not need rekey
#expect(sessions.isEmpty || sessions.allSatisfy { !$0.needsRekey })
}
@Test func handshakeAfterDecryptionFailure() throws {
// Test that handshake is properly initiated after decryption failure
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
// Establish sessions
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
// Create a corrupted message
var encrypted = try aliceManager.encrypt("Test".data(using: .utf8)!, for: alicePeerID)
encrypted[10] ^= 0xFF // Corrupt the data
// Decryption should fail
if #available(macOS 14.4, iOS 17.4, *) {
#expect(throws: CryptoKitError.authenticationFailure) {
try bobManager.decrypt(encrypted, from: bobPeerID)
}
} else {
#expect(throws: (any Error).self) {
try bobManager.decrypt(encrypted, from: bobPeerID)
}
}
// Bob should still have the session (it's not removed on single failure)
#expect(bobManager.getSession(for: bobPeerID) != nil)
}
@Test func handshakeAlwaysAcceptedWithExistingSession() throws {
// Test that handshake is always accepted even with existing valid session
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
// Establish sessions
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
// Verify sessions are established
#expect(aliceManager.getSession(for: alicePeerID)?.isEstablished() == true)
#expect(bobManager.getSession(for: bobPeerID)?.isEstablished() == true)
// Exchange messages to verify sessions work
let testMessage = "Session works".data(using: .utf8)!
let encrypted = try aliceManager.encrypt(testMessage, for: alicePeerID)
let decrypted = try bobManager.decrypt(encrypted, from: bobPeerID)
#expect(decrypted == testMessage)
// Alice clears her session (simulating decryption failure)
aliceManager.removeSession(for: alicePeerID)
// Alice initiates new handshake despite Bob having valid session
let newHandshake1 = try aliceManager.initiateHandshake(with: alicePeerID)
// Bob should accept the new handshake even though he has a valid session
let newHandshake2 = try bobManager.handleIncomingHandshake(
from: bobPeerID, message: newHandshake1)
#expect(newHandshake2 != nil, "Bob should accept handshake despite having valid session")
// Complete the handshake
let newHandshake3 = try aliceManager.handleIncomingHandshake(
from: alicePeerID, message: newHandshake2!)
#expect(newHandshake3 != nil)
_ = try bobManager.handleIncomingHandshake(from: bobPeerID, message: newHandshake3!)
// Verify new sessions work
let testMessage2 = "New session works".data(using: .utf8)!
let encrypted2 = try aliceManager.encrypt(testMessage2, for: alicePeerID)
let decrypted2 = try bobManager.decrypt(encrypted2, from: bobPeerID)
#expect(decrypted2 == testMessage2)
}
@Test func nonceDesynchronizationCausesRehandshake() throws {
// Test that nonce desynchronization leads to proper re-handshake
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
// Establish sessions
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
// Exchange messages normally
for i in 0..<5 {
let msg = try aliceManager.encrypt("Message \(i)".data(using: .utf8)!, for: alicePeerID)
_ = try bobManager.decrypt(msg, from: bobPeerID)
}
// Simulate desynchronization - Alice sends messages that Bob doesn't receive
for i in 0..<3 {
_ = try aliceManager.encrypt("Lost message \(i)".data(using: .utf8)!, for: alicePeerID)
}
// With nonce carried in packet, decryption should not throw here
let desyncMessage = try aliceManager.encrypt(
"This now succeeds".data(using: .utf8)!, for: alicePeerID)
#expect(throws: Never.self) {
try bobManager.decrypt(desyncMessage, from: bobPeerID)
}
// Bob clears session and initiates new handshake
bobManager.removeSession(for: bobPeerID)
let rehandshake1 = try bobManager.initiateHandshake(with: bobPeerID)
// Alice should accept despite having a "valid" (but desynced) session
let rehandshake2 = try aliceManager.handleIncomingHandshake(
from: alicePeerID, message: rehandshake1)
#expect(rehandshake2 != nil, "Alice should accept handshake to fix desync")
// Complete handshake
let rehandshake3 = try bobManager.handleIncomingHandshake(
from: bobPeerID, message: rehandshake2!)
#expect(rehandshake3 != nil)
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: rehandshake3!)
// Verify communication works again
let testResynced = "Resynced".data(using: .utf8)!
let encryptedResync = try aliceManager.encrypt(testResynced, for: alicePeerID)
let decryptedResync = try bobManager.decrypt(encryptedResync, from: bobPeerID)
#expect(decryptedResync == testResynced)
}
// MARK: - Test Vector Tests
@Test func noiseTestVectors() throws {
// Load test vectors from bundle
let testVectors = try loadTestVectors()
for (index, testVector) in testVectors.enumerated() {
print("Running test vector \(index + 1): \(testVector.protocol_name)")
try runTestVector(testVector)
}
}
// MARK: - Helper Methods
private func performHandshake(initiator: NoiseSession, responder: NoiseSession) throws {
let msg1 = try initiator.startHandshake()
let msg2 = try responder.processHandshakeMessage(msg1)!
let msg3 = try initiator.processHandshakeMessage(msg2)!
_ = try responder.processHandshakeMessage(msg3)
}
private func establishManagerSessions(
aliceManager: NoiseSessionManager, bobManager: NoiseSessionManager
) throws {
let msg1 = try aliceManager.initiateHandshake(with: alicePeerID)
let msg2 = try bobManager.handleIncomingHandshake(from: bobPeerID, message: msg1)!
let msg3 = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: msg2)!
_ = try bobManager.handleIncomingHandshake(from: bobPeerID, message: msg3)
}
private func loadTestVectors() throws -> [NoiseTestVector] {
// Try to load from test bundle
guard let url = Bundle.module.url(forResource: "NoiseTestVectors", withExtension: "json") else {
throw NSError(
domain: "NoiseTests", code: 1,
userInfo: [
NSLocalizedDescriptionKey: "Could not find NoiseTestVectors.json in test bundle"
])
}
let data = try Data(contentsOf: url)
return try JSONDecoder().decode([NoiseTestVector].self, from: data)
}
private func runTestVector(_ testVector: NoiseTestVector) throws {
// Parse test inputs
guard let initStatic = Data(hex: testVector.init_static),
let initEphemeral = Data(hex: testVector.init_ephemeral),
let respStatic = Data(hex: testVector.resp_static),
let respEphemeral = Data(hex: testVector.resp_ephemeral),
let prologue = Data(hex: testVector.init_prologue)
else {
throw NSError(
domain: "NoiseTests", code: 2,
userInfo: [NSLocalizedDescriptionKey: "Failed to parse test vector hex strings"])
}
let expectedHash = testVector.handshake_hash.flatMap { Data(hex: $0) }
// Create keys
guard
let initStaticKey = try? Curve25519.KeyAgreement.PrivateKey(
rawRepresentation: initStatic),
let initEphemeralKey = try? Curve25519.KeyAgreement.PrivateKey(
rawRepresentation: initEphemeral),
let respStaticKey = try? Curve25519.KeyAgreement.PrivateKey(
rawRepresentation: respStatic),
let respEphemeralKey = try? Curve25519.KeyAgreement.PrivateKey(
rawRepresentation: respEphemeral)
else {
throw NSError(
domain: "NoiseTests", code: 3,
userInfo: [NSLocalizedDescriptionKey: "Failed to create keys from test vectors"])
}
let keychain = MockKeychain()
// Create handshake states
let initiatorHandshake = NoiseHandshakeState(
role: .initiator,
pattern: .XX,
keychain: keychain,
localStaticKey: initStaticKey,
prologue: prologue,
predeterminedEphemeralKey: initEphemeralKey
)
let responderHandshake = NoiseHandshakeState(
role: .responder,
pattern: .XX,
keychain: keychain,
localStaticKey: respStaticKey,
prologue: prologue,
predeterminedEphemeralKey: respEphemeralKey
)
// For XX pattern, we have 3 handshake messages, then transport messages
// The test vector messages are ordered as: [msg1, msg2, msg3, transport1, transport2, ...]
guard testVector.messages.count >= 3 else {
throw NSError(
domain: "NoiseTests", code: 5,
userInfo: [NSLocalizedDescriptionKey: "Test vector must have at least 3 messages for XX pattern"])
}
// Message 1: Initiator -> Responder (e)
guard let payload1 = Data(hex: testVector.messages[0].payload),
let expectedCiphertext1 = Data(hex: testVector.messages[0].ciphertext) else {
throw NSError(
domain: "NoiseTests", code: 4,
userInfo: [NSLocalizedDescriptionKey: "Message 1: Failed to parse hex"])
}
let msg1 = try initiatorHandshake.writeMessage(payload: payload1)
#expect(!msg1.isEmpty, "Message 1 should not be empty")
#expect(msg1 == expectedCiphertext1, "Message 1 ciphertext should match expected value. Got: \(msg1.hexString()), Expected: \(expectedCiphertext1.hexString())")
let decrypted1 = try responderHandshake.readMessage(msg1)
#expect(decrypted1 == payload1, "Message 1: Decrypted payload should match original")
// Message 2: Responder -> Initiator (e, ee, s, es)
guard let payload2 = Data(hex: testVector.messages[1].payload),
let expectedCiphertext2 = Data(hex: testVector.messages[1].ciphertext) else {
throw NSError(
domain: "NoiseTests", code: 4,
userInfo: [NSLocalizedDescriptionKey: "Message 2: Failed to parse hex"])
}
let msg2 = try responderHandshake.writeMessage(payload: payload2)
#expect(!msg2.isEmpty, "Message 2 should not be empty")
#expect(msg2 == expectedCiphertext2, "Message 2 ciphertext should match expected value. Got: \(msg2.hexString()), Expected: \(expectedCiphertext2.hexString())")
let decrypted2 = try initiatorHandshake.readMessage(msg2)
#expect(decrypted2 == payload2, "Message 2: Decrypted payload should match original")
// Message 3: Initiator -> Responder (s, se)
guard let payload3 = Data(hex: testVector.messages[2].payload),
let expectedCiphertext3 = Data(hex: testVector.messages[2].ciphertext) else {
throw NSError(
domain: "NoiseTests", code: 4,
userInfo: [NSLocalizedDescriptionKey: "Message 3: Failed to parse hex"])
}
let msg3 = try initiatorHandshake.writeMessage(payload: payload3)
#expect(!msg3.isEmpty, "Message 3 should not be empty")
#expect(msg3 == expectedCiphertext3, "Message 3 ciphertext should match expected value. Got: \(msg3.hexString()), Expected: \(expectedCiphertext3.hexString())")
let decrypted3 = try responderHandshake.readMessage(msg3)
#expect(decrypted3 == payload3, "Message 3: Decrypted payload should match original")
// Verify handshake hash
let initiatorHash = initiatorHandshake.getHandshakeHash()
let responderHash = responderHandshake.getHandshakeHash()
#expect(initiatorHash == responderHash, "Initiator and responder hashes should match")
if let expectedHash = expectedHash {
#expect(
initiatorHash == expectedHash,
"Handshake hash should match expected value from test vector. Got: \(initiatorHash.hexString()), Expected: \(expectedHash.hexString())")
}
// Get transport ciphers
let (initSend, initRecv, _) = try initiatorHandshake.getTransportCiphers(useExtractedNonce: false)
let (respSend, respRecv, _) = try responderHandshake.getTransportCiphers(useExtractedNonce: false)
// Test transport messages (messages after the 3 handshake messages)
for index in 3..<testVector.messages.count {
let testMsg = testVector.messages[index]
guard let payload = Data(hex: testMsg.payload),
let expectedCiphertext = Data(hex: testMsg.ciphertext) else {
throw NSError(
domain: "NoiseTests", code: 4,
userInfo: [
NSLocalizedDescriptionKey:
"Message \(index + 1): Failed to parse payload hex"
])
}
// Alternate between responder and initiator sending
// Responder sends first transport message (since initiator sent last handshake message)
let (sender, receiver): (NoiseCipherState, NoiseCipherState)
let transportIndex = index - 3
if transportIndex % 2 == 0 {
// Even transport messages: responder sends
sender = respSend
receiver = initRecv
} else {
// Odd transport messages: initiator sends
sender = initSend
receiver = respRecv
}
// Encrypt and validate ciphertext matches expected value
let ciphertext = try sender.encrypt(plaintext: payload)
#expect(
ciphertext == expectedCiphertext,
"Message \(index + 1) ciphertext should match expected value. Got: \(ciphertext.hexString()), Expected: \(expectedCiphertext.hexString())")
// Decrypt and validate payload
let decrypted = try receiver.decrypt(ciphertext: ciphertext)
#expect(
decrypted == payload,
"Message \(index + 1): Decrypted payload should match original")
}
}
// MARK: - DH Shared Secret Clearing Tests
@Test func secureClearCalledDuringHandshake() throws {
// Use TrackingMockKeychain to verify secureClear is called
let trackingKeychain = TrackingMockKeychain()
let aliceKey = Curve25519.KeyAgreement.PrivateKey()
let bobKey = Curve25519.KeyAgreement.PrivateKey()
let alice = NoiseSession(
peerID: PeerID(str: "alice-test"),
role: .initiator,
keychain: trackingKeychain,
localStaticKey: aliceKey
)
let bob = NoiseSession(
peerID: PeerID(str: "bob-test"),
role: .responder,
keychain: trackingKeychain,
localStaticKey: bobKey
)
// Perform handshake
let msg1 = try alice.startHandshake()
let msg2 = try bob.processHandshakeMessage(msg1)!
let msg3 = try alice.processHandshakeMessage(msg2)!
_ = try bob.processHandshakeMessage(msg3)
// In Noise XX pattern handshake:
// - Message 1 (initiator): e token only (no DH)
// - Message 2 (responder): e, ee, s, es tokens (2 DH operations: ee, es)
// - Message 3 (initiator): s, se tokens (1 DH operation: se)
// Total in writeMessage: 3 DH operations (ee, es, se)
//
// In readMessage (performDHOperation):
// - After msg1: no DH
// - After msg2: ee, es (2 DH operations)
// - After msg3: se (1 DH operation)
// Total in performDHOperation: 3 DH operations
//
// Grand total: 6 DH operations requiring secureClear
//
// Note: .ss pattern is only used in certain handshake patterns, not XX
let expectedMinimumCalls = 6
#expect(
trackingKeychain.secureClearDataCallCount >= expectedMinimumCalls,
"Expected at least \(expectedMinimumCalls) secureClear calls for DH secrets, got \(trackingKeychain.secureClearDataCallCount)"
)
}
@Test func encryptionWorksAfterSecureClear() throws {
// Verify that encryption/decryption still works correctly after adding secureClear
let trackingKeychain = TrackingMockKeychain()
let aliceKey = Curve25519.KeyAgreement.PrivateKey()
let bobKey = Curve25519.KeyAgreement.PrivateKey()
let alice = NoiseSession(
peerID: PeerID(str: "alice-test-enc"),
role: .initiator,
keychain: trackingKeychain,
localStaticKey: aliceKey
)
let bob = NoiseSession(
peerID: PeerID(str: "bob-test-enc"),
role: .responder,
keychain: trackingKeychain,
localStaticKey: bobKey
)
// Perform handshake
let msg1 = try alice.startHandshake()
let msg2 = try bob.processHandshakeMessage(msg1)!
let msg3 = try alice.processHandshakeMessage(msg2)!
_ = try bob.processHandshakeMessage(msg3)
// Verify both sessions are established
#expect(alice.isEstablished())
#expect(bob.isEstablished())
// Verify secureClear was called (basic sanity check)
#expect(trackingKeychain.secureClearDataCallCount > 0)
// Test encryption from Alice to Bob
let plaintext1 = "Hello from Alice after secureClear!".data(using: .utf8)!
let ciphertext1 = try alice.encrypt(plaintext1)
let decrypted1 = try bob.decrypt(ciphertext1)
#expect(decrypted1 == plaintext1)
// Test encryption from Bob to Alice
let plaintext2 = "Hello from Bob after secureClear!".data(using: .utf8)!
let ciphertext2 = try bob.encrypt(plaintext2)
let decrypted2 = try alice.decrypt(ciphertext2)
#expect(decrypted2 == plaintext2)
// Test multiple messages to verify cipher state is correct
for i in 1...10 {
let msg = "Message \(i) from Alice".data(using: .utf8)!
let cipher = try alice.encrypt(msg)
let dec = try bob.decrypt(cipher)
#expect(dec == msg)
}
}
@Test func secureClearCalledInBothWriteAndReadPaths() throws {
// Verify secureClear is called in both writeMessage and readMessage paths
// We do this by checking the count increases at each step
let aliceKeychain = TrackingMockKeychain()
let bobKeychain = TrackingMockKeychain()
let aliceKey = Curve25519.KeyAgreement.PrivateKey()
let bobKey = Curve25519.KeyAgreement.PrivateKey()
let alice = NoiseSession(
peerID: PeerID(str: "alice-paths"),
role: .initiator,
keychain: aliceKeychain,
localStaticKey: aliceKey
)
let bob = NoiseSession(
peerID: PeerID(str: "bob-paths"),
role: .responder,
keychain: bobKeychain,
localStaticKey: bobKey
)
// Message 1: Alice writes (e token only, no DH)
let msg1 = try alice.startHandshake()
let aliceCountAfterMsg1 = aliceKeychain.secureClearDataCallCount
// No DH in message 1 for initiator
#expect(aliceCountAfterMsg1 == 0, "No DH secrets in message 1 write")
// Bob reads message 1 (no DH) and writes message 2 (ee, es DH operations)
let msg2 = try bob.processHandshakeMessage(msg1)!
let bobCountAfterMsg2 = bobKeychain.secureClearDataCallCount
// Bob should have cleared secrets for: ee (read), es (read), ee (write), es (write)
#expect(bobCountAfterMsg2 >= 2, "Bob should clear DH secrets when processing/writing message 2")
// Alice reads message 2 (ee, es) and writes message 3 (se)
let msg3 = try alice.processHandshakeMessage(msg2)!
let aliceCountAfterMsg3 = aliceKeychain.secureClearDataCallCount
// Alice should have cleared: ee (read), es (read), se (write)
#expect(aliceCountAfterMsg3 >= 3, "Alice should clear DH secrets when processing/writing message 3")
// Bob reads message 3 (se)
_ = try bob.processHandshakeMessage(msg3)
let bobFinalCount = bobKeychain.secureClearDataCallCount
// Bob should have additionally cleared: se (read)
#expect(bobFinalCount > bobCountAfterMsg2, "Bob should clear DH secrets when processing message 3")
}
}
@@ -0,0 +1,70 @@
import XCTest
import BitFoundation
@testable import Noise
final class NoiseRateLimiterTests: XCTestCase {
func test_allowHandshake_blocksAfterPerPeerLimit() {
let limiter = NoiseRateLimiter()
let peerID = makePeerID(1)
for _ in 0..<NoiseSecurityConstants.maxHandshakesPerMinute {
XCTAssertTrue(limiter.allowHandshake(from: peerID))
}
XCTAssertFalse(limiter.allowHandshake(from: peerID))
}
func test_allowHandshake_blocksAfterGlobalLimitAcrossPeers() {
let limiter = NoiseRateLimiter()
for index in 0..<NoiseSecurityConstants.maxGlobalHandshakesPerMinute {
XCTAssertTrue(limiter.allowHandshake(from: makePeerID(index)))
}
XCTAssertFalse(limiter.allowHandshake(from: makePeerID(10_000)))
}
func test_reset_clearsPerPeerHandshakeLimit() async {
let limiter = NoiseRateLimiter()
let peerID = makePeerID(7)
for _ in 0..<NoiseSecurityConstants.maxHandshakesPerMinute {
XCTAssertTrue(limiter.allowHandshake(from: peerID))
}
XCTAssertFalse(limiter.allowHandshake(from: peerID))
limiter.reset(for: peerID)
try? await Task.sleep(nanoseconds: 50_000_000)
XCTAssertTrue(limiter.allowHandshake(from: peerID))
}
func test_allowMessage_blocksAfterPerPeerLimit() {
let limiter = NoiseRateLimiter()
let peerID = makePeerID(9)
for _ in 0..<NoiseSecurityConstants.maxMessagesPerSecond {
XCTAssertTrue(limiter.allowMessage(from: peerID))
}
XCTAssertFalse(limiter.allowMessage(from: peerID))
}
func test_resetAll_clearsGlobalHandshakeLimit() async {
let limiter = NoiseRateLimiter()
for index in 0..<NoiseSecurityConstants.maxGlobalHandshakesPerMinute {
XCTAssertTrue(limiter.allowHandshake(from: makePeerID(index)))
}
XCTAssertFalse(limiter.allowHandshake(from: makePeerID(20_000)))
limiter.resetAll()
try? await Task.sleep(nanoseconds: 50_000_000)
XCTAssertTrue(limiter.allowHandshake(from: makePeerID(20_001)))
}
private func makePeerID(_ value: Int) -> PeerID {
PeerID(str: String(format: "%016x", value))
}
}
@@ -0,0 +1,71 @@
[
{
"protocol_name": "Noise_XX_25519_ChaChaPoly_SHA256",
"init_prologue": "4a6f686e2047616c74",
"init_static": "e61ef9919cde45dd5f82166404bd08e38bceb5dfdfded0a34c8df7ed542214d1",
"init_ephemeral": "893e28b9dc6ca8d611ab664754b8ceb7bac5117349a4439a6b0569da977c464a",
"resp_prologue": "4a6f686e2047616c74",
"resp_static": "4a3acbfdb163dec651dfa3194dece676d437029c62a408b4c5ea9114246e4893",
"resp_ephemeral": "bbdb4cdbd309f1a1f2e1456967fe288cadd6f712d65dc7b7793d5e63da6b375b",
"handshake_hash": "c8e5f64e846193be2a834104c2a009868d6c9f3bd3c186299888b488b2f1f58e",
"messages": [
{
"payload": "4c756477696720766f6e204d69736573",
"ciphertext": "ca35def5ae56cec33dc2036731ab14896bc4c75dbb07a61f879f8e3afa4c79444c756477696720766f6e204d69736573"
},
{
"payload": "4d757272617920526f746862617264",
"ciphertext": "95ebc60d2b1fa672c1f46a8aa265ef51bfe38e7ccb39ec5be34069f14480884381cbad1f276e038c48378ffce2b65285e08d6b68aaa3629a5a8639392490e5b9bd5269c2f1e4f488ed8831161f19b7815528f8982ffe09be9b5c412f8a0db50f8814c7194e83f23dbd8d162c9326ad"
},
{
"payload": "462e20412e20486179656b",
"ciphertext": "c7195ffacac1307ff99046f219750fc47693e23c3cb08b89c2af808b444850a80ae475b9df0f169ae80a89be0865b57f58c9fea0d4ec82a286427402f113e4b6ae769a1d95941d49b25030"
},
{
"payload": "4361726c204d656e676572",
"ciphertext": "96763ed773f8e47bb3712f0e29b3060ffc956ffc146cee53d5e1df"
},
{
"payload": "4a65616e2d426170746973746520536179",
"ciphertext": "3e40f15f6f3a46ae446b253bf8b1d9ffb6ed9b174d272328ff91a7e2e5c79c07f5"
},
{
"payload": "457567656e2042f6686d20766f6e2042617765726b",
"ciphertext": "eb3f3515110702e047a6c9da4478b6ead94873c11c0f2d710ddb3f09fce024b3a58502ae3f"
}
]
},
{
"protocol_name": "Noise_XX_25519_ChaChaPoly_SHA256",
"init_prologue": "5468657265206973206e6f20726967687420616e642077726f6e672e2054686572652773206f6e6c792066756e20616e6420626f72696e672e",
"init_psks": [],
"init_static": "7dec208517a3b81a2861d7a71266d5d6dc944c5a8816634a86fe63198a0148ee",
"init_ephemeral": "a32daf21e93c0131495ce1d903181fde81cc46937daaeb990bae7c992709421e",
"resp_prologue": "5468657265206973206e6f20726967687420616e642077726f6e672e2054686572652773206f6e6c792066756e20616e6420626f72696e672e",
"resp_psks": [],
"resp_static": "4d0aed5098e3b4ef20357e9f686ce66204c792b358da2e475017d6c485304881",
"resp_ephemeral": "4eece0f195d026db035ff987597c429d3ad3bcc2944df37d649528951b2a27c5",
"messages": [
{
"payload": "d03c489139e645d0711a3c9e810d776b46a84912463fafa87b884eebf242dc34",
"ciphertext": "f9fa868ba97ab8a2686deccfaad5a484ee10a5bb85e3d1dce015a84797f92818d03c489139e645d0711a3c9e810d776b46a84912463fafa87b884eebf242dc34"
},
{
"payload": "d8190a92f7dc0c93dbea9118ba8055751fb7c6590c416ffbd419964132b99a85",
"ciphertext": "8c4e6fdb7d09d501a86f7eca5c234522751706ed409182c05cdf5f827d4dae47b81c6c5f43b025692c24391eefee725c17d8cb0fbe3e4abb8aedf42c4fd2592d4ea48ac08989d6ae8b4adae08b2c34087c808c7aa55a63c02b0fab9e930612336bd43eaea04d3c670a0a146691aa9cc9d357872320dc735dbc48580cffb553db"
},
{
"payload": "77891b19dcb92ef7c055b672c4a5aa7fdf1c84146b8b303459022729473ce254",
"ciphertext": "933ca6b5ed60df3df66121f0ab49a09e49efa45c613a86a3cecbf4c535cef2f83f72b42837b18e3572f2fdc2b74c331e2368a545cef54bdca081678ab0e9dd5348122459e0c034c851984d88ce610963d43cde6cfe73a67fbd5a63e8bfca96d0"
},
{
"payload": "d7efdf988072881941db045a42882433817555128fbf5663e56081712ec7d212",
"ciphertext": "54ef0ff0629e1aaa7685a2806ab111cba76b52331f2642276736f415868eacb69ab2577f3bda0cbf72f879685f6ed25f"
},
{
"payload": "dd7bf01a588bafb52c6cfba952e5d8fe35cc2b3f92b4730ae2474615157345ce",
"ciphertext": "356be70f110306d5c699bb834bb9d58d909e325924dfbec972e406e6f294dc63e1daebefe8a62a334facc8048ab4ad66"
}
]
}
]
@@ -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))
}