mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
* Improve BLE mesh reliability for large transfers Major reliability improvements for fragment-based transfers (photos, files): **Notification Queue Fixes** - Fix silent packet loss when notification queue is full - now queues for retry - Fix retry queue bug where remaining items were lost when one retry failed - Add periodic drain mechanism as backup (every 5 seconds) **Write Queue Fixes** - Fix drainPendingWrites to use atomic take-send-requeue pattern - Add logging when peripheral is ready for more writes - Add periodic drain for pending writes as backup **Fragment Pacing** - Increase fragment spacing from 4-5ms to 25-30ms to prevent buffer overflow - Conservative pacing prevents packet loss on congested BLE connections **Thread Safety** - Fix race conditions in stopServices() and emergencyDisconnectAll() - Synchronize access to peripherals/centrals dictionaries during cleanup - Clear pending message queues in emergencyDisconnectAll() **Error Recovery** - Add handlers for all BLE state transitions (poweredOff, unauthorized, etc.) - Clear Noise session and re-initiate handshake on decryption failure - Queue ACKs/receipts for delivery after handshake instead of dropping - Re-queue failed pending messages for retry **Other Improvements** - Add route freshness validation in MeshTopologyTracker (60s threshold) - Add TTL (24h) and size limits (100 per peer) for MessageRouter outbox - Fix connection timeout to check peripheral.state before canceling - Add NoiseEncryptionService.clearSession(for:) method Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Fix race condition and increase test timeouts - Fix race in sendNoisePayload: use sync barrier instead of async to ensure payload is queued before initiating handshake (addresses Codex review feedback) - Increase BLEServiceTests sleep from 0.5s to 1.0s for CI reliability Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
772 lines
30 KiB
Swift
772 lines
30 KiB
Swift
//
|
|
// 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 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
|
|
}
|