mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 20:45:19 +00:00
Extract Noise into a dedicated module
This commit is contained in:
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user