mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 21:45:19 +00:00
1630 lines
60 KiB
Swift
1630 lines
60 KiB
Swift
import Foundation
|
|
import Testing
|
|
import BitFoundation
|
|
@testable import bitchat
|
|
|
|
@Suite("NoiseEncryptionService Tests", .serialized)
|
|
struct NoiseEncryptionServiceTests {
|
|
|
|
@Test("Encryption status accessors cover all cases")
|
|
func encryptionStatusAccessorsCoverAllCases() {
|
|
#expect(EncryptionStatus.none.icon == "lock.slash")
|
|
#expect(EncryptionStatus.noHandshake.icon == nil)
|
|
#expect(EncryptionStatus.noiseHandshaking.icon == "lock.rotation")
|
|
#expect(EncryptionStatus.noiseSecured.icon == "lock.fill")
|
|
#expect(EncryptionStatus.noiseVerified.icon == "checkmark.seal.fill")
|
|
|
|
#expect(!EncryptionStatus.none.description.isEmpty)
|
|
#expect(!EncryptionStatus.noHandshake.description.isEmpty)
|
|
#expect(!EncryptionStatus.noiseHandshaking.description.isEmpty)
|
|
#expect(!EncryptionStatus.noiseSecured.description.isEmpty)
|
|
#expect(!EncryptionStatus.noiseVerified.description.isEmpty)
|
|
|
|
#expect(!EncryptionStatus.none.accessibilityDescription.isEmpty)
|
|
#expect(!EncryptionStatus.noHandshake.accessibilityDescription.isEmpty)
|
|
#expect(!EncryptionStatus.noiseHandshaking.accessibilityDescription.isEmpty)
|
|
#expect(!EncryptionStatus.noiseSecured.accessibilityDescription.isEmpty)
|
|
#expect(!EncryptionStatus.noiseVerified.accessibilityDescription.isEmpty)
|
|
}
|
|
|
|
@Test("Announce and packet signatures round-trip and detect tampering")
|
|
func announceAndPacketSignaturesRoundTrip() throws {
|
|
let service = NoiseEncryptionService(keychain: MockKeychain())
|
|
let signingPublicKey = service.getSigningPublicKeyData()
|
|
let noisePublicKey = service.getStaticPublicKeyData()
|
|
|
|
let signature = try #require(
|
|
service.buildAnnounceSignature(
|
|
peerID: Data([0xAA, 0xBB]),
|
|
noiseKey: noisePublicKey,
|
|
ed25519Key: signingPublicKey,
|
|
nickname: "Alice",
|
|
timestampMs: 12345
|
|
),
|
|
"Expected announce signature"
|
|
)
|
|
|
|
#expect(
|
|
service.verifyAnnounceSignature(
|
|
signature: signature,
|
|
peerID: Data([0xAA, 0xBB]),
|
|
noiseKey: noisePublicKey,
|
|
ed25519Key: signingPublicKey,
|
|
nickname: "Alice",
|
|
timestampMs: 12345,
|
|
publicKey: signingPublicKey
|
|
)
|
|
)
|
|
#expect(
|
|
!service.verifyAnnounceSignature(
|
|
signature: signature,
|
|
peerID: Data([0xAA, 0xBB]),
|
|
noiseKey: noisePublicKey,
|
|
ed25519Key: signingPublicKey,
|
|
nickname: "Mallory",
|
|
timestampMs: 12345,
|
|
publicKey: signingPublicKey
|
|
)
|
|
)
|
|
#expect(!service.verifySignature(signature, for: Data("data".utf8), publicKey: Data([1, 2, 3])))
|
|
|
|
let packet = BitchatPacket(
|
|
type: MessageType.announce.rawValue,
|
|
senderID: Data([0, 1, 2, 3, 4, 5, 6, 7]),
|
|
recipientID: nil,
|
|
timestamp: 42,
|
|
payload: Data("payload".utf8),
|
|
signature: nil,
|
|
ttl: 7
|
|
)
|
|
let signedPacket = try #require(service.signPacket(packet), "Expected signed packet")
|
|
|
|
#expect(service.verifyPacketSignature(signedPacket, publicKey: signingPublicKey))
|
|
#expect(!service.verifyPacketSignature(packet, publicKey: signingPublicKey))
|
|
|
|
var tampered = signedPacket
|
|
tampered.signature = Data(repeating: 0xFF, count: 64)
|
|
#expect(!service.verifyPacketSignature(tampered, publicKey: signingPublicKey))
|
|
}
|
|
|
|
@Test("Service-level handshake, encryption, and fingerprint lifecycle work")
|
|
func handshakeEncryptionAndFingerprintLifecycle() async throws {
|
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
|
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
|
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
|
let recorder = AuthenticationRecorder()
|
|
|
|
#expect(alice.onPeerAuthenticated == nil)
|
|
#expect(bob.onPeerAuthenticatedWithGeneration == nil)
|
|
alice.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:))
|
|
bob.onPeerAuthenticated = recorder.record(peerID:fingerprint:)
|
|
bob.onPeerAuthenticatedWithGeneration = recorder.record(
|
|
peerID:fingerprint:sessionGeneration:
|
|
)
|
|
|
|
try establishSessions(alice: alice, bob: bob)
|
|
|
|
let authenticated = await TestHelpers.waitUntil({ recorder.count >= 2 }, timeout: 5.0)
|
|
#expect(authenticated)
|
|
let generationAuthenticated = await TestHelpers.waitUntil(
|
|
{ recorder.generationCount >= 1 },
|
|
timeout: 5.0
|
|
)
|
|
#expect(generationAuthenticated)
|
|
#expect(alice.hasEstablishedSession(with: bobPeerID))
|
|
#expect(bob.hasEstablishedSession(with: alicePeerID))
|
|
#expect(alice.hasSession(with: bobPeerID))
|
|
#expect(bob.hasSession(with: alicePeerID))
|
|
#expect(alice.getPeerPublicKeyData(bobPeerID)?.count == 32)
|
|
#expect(bob.getPeerPublicKeyData(alicePeerID)?.count == 32)
|
|
#expect(alice.getPeerFingerprint(bobPeerID) != nil)
|
|
#expect(bob.getPeerFingerprint(alicePeerID) != nil)
|
|
#expect(recorder.generation(for: alicePeerID) == bob.sessionGeneration(for: alicePeerID))
|
|
|
|
let plaintext = Data("secret payload".utf8)
|
|
let ciphertext = try alice.encrypt(plaintext, for: bobPeerID)
|
|
let decrypted = try bob.decrypt(ciphertext, from: alicePeerID)
|
|
#expect(decrypted == plaintext)
|
|
|
|
alice.clearSession(for: bobPeerID)
|
|
#expect(!alice.hasSession(with: bobPeerID))
|
|
#expect(alice.getPeerFingerprint(bobPeerID) == nil)
|
|
|
|
bob.clearEphemeralStateForPanic()
|
|
#expect(!bob.hasSession(with: alicePeerID))
|
|
#expect(bob.getPeerFingerprint(alicePeerID) == nil)
|
|
}
|
|
|
|
@Test("Handshake rejects a claimed peer ID that does not match the authenticated static key")
|
|
func handshakeRejectsClaimedPeerIDStaticKeyMismatch() async throws {
|
|
let receiver = NoiseEncryptionService(keychain: MockKeychain())
|
|
let claimedAlice = NoiseEncryptionService(keychain: MockKeychain())
|
|
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
|
let receiverPeerID = PeerID(publicKey: receiver.getStaticPublicKeyData())
|
|
let claimedAlicePeerID = PeerID(publicKey: claimedAlice.getStaticPublicKeyData())
|
|
let recorder = AuthenticationRecorder()
|
|
receiver.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:))
|
|
|
|
let message1 = try mallory.initiateHandshake(with: receiverPeerID)
|
|
let message2 = try #require(
|
|
try receiver.processHandshakeMessage(from: claimedAlicePeerID, message: message1)
|
|
)
|
|
let message3 = try #require(
|
|
try mallory.processHandshakeMessage(from: receiverPeerID, message: message2)
|
|
)
|
|
|
|
do {
|
|
_ = try receiver.processHandshakeMessage(from: claimedAlicePeerID, message: message3)
|
|
Issue.record("Expected the authenticated Mallory key to be rejected for Alice's peer ID")
|
|
} catch let error as NoiseSessionError {
|
|
#expect(error == .peerIdentityMismatch)
|
|
} catch {
|
|
Issue.record("Unexpected mismatch error: \(error)")
|
|
}
|
|
|
|
#expect(!receiver.hasSession(with: claimedAlicePeerID))
|
|
let emittedAuthentication = await TestHelpers.waitUntil(
|
|
{ recorder.count > 0 },
|
|
timeout: TestConstants.shortTimeout
|
|
)
|
|
#expect(!emittedAuthentication)
|
|
}
|
|
|
|
@Test("Failed forged reconnect restores the established peer session")
|
|
func forgedReconnectRestoresEstablishedSession() async throws {
|
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
|
let receiver = NoiseEncryptionService(keychain: MockKeychain())
|
|
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
|
let receiverPeerID = PeerID(publicKey: receiver.getStaticPublicKeyData())
|
|
let recorder = AuthenticationRecorder()
|
|
receiver.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:))
|
|
|
|
try establishSessions(alice: alice, bob: receiver)
|
|
let initialAuthentication = await TestHelpers.waitUntil(
|
|
{ recorder.count == 1 },
|
|
timeout: TestConstants.longTimeout
|
|
)
|
|
#expect(initialAuthentication)
|
|
|
|
let before = try alice.encrypt(Data("before".utf8), for: receiverPeerID)
|
|
#expect(try receiver.decrypt(before, from: alicePeerID) == Data("before".utf8))
|
|
|
|
let forgedMessage1 = try mallory.initiateHandshake(with: receiverPeerID)
|
|
let forgedMessage2 = try #require(
|
|
try receiver.processHandshakeMessage(from: alicePeerID, message: forgedMessage1)
|
|
)
|
|
// Outbound/session-generation APIs fail closed while the old
|
|
// transport is retained solely for receive and bounded rollback.
|
|
#expect(!receiver.hasEstablishedSession(with: alicePeerID))
|
|
let forgedMessage3 = try #require(
|
|
try mallory.processHandshakeMessage(from: receiverPeerID, message: forgedMessage2)
|
|
)
|
|
|
|
do {
|
|
_ = try receiver.processHandshakeMessage(from: alicePeerID, message: forgedMessage3)
|
|
Issue.record("Expected forged reconnect to fail peer binding")
|
|
} catch let error as NoiseSessionError {
|
|
#expect(error == .peerIdentityMismatch)
|
|
} catch {
|
|
Issue.record("Unexpected reconnect error: \(error)")
|
|
}
|
|
|
|
#expect(receiver.hasEstablishedSession(with: alicePeerID))
|
|
let after = try alice.encrypt(Data("after".utf8), for: receiverPeerID)
|
|
#expect(try receiver.decrypt(after, from: alicePeerID) == Data("after".utf8))
|
|
let emittedReplacementAuthentication = await TestHelpers.waitUntil(
|
|
{ recorder.count > 1 },
|
|
timeout: TestConstants.shortTimeout
|
|
)
|
|
#expect(!emittedReplacementAuthentication)
|
|
}
|
|
|
|
@Test("Valid ordinary rehandshake atomically replaces the established session")
|
|
func validOrdinaryRehandshakeReplacesEstablishedSession() throws {
|
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
|
let receiver = NoiseEncryptionService(keychain: MockKeychain())
|
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
|
let receiverPeerID = PeerID(publicKey: receiver.getStaticPublicKeyData())
|
|
|
|
try establishSessions(alice: alice, bob: receiver)
|
|
alice.clearSession(for: receiverPeerID)
|
|
|
|
let message1 = try alice.initiateHandshake(with: receiverPeerID)
|
|
let message2 = try #require(
|
|
try receiver.processHandshakeMessage(from: alicePeerID, message: message1)
|
|
)
|
|
#expect(!receiver.hasEstablishedSession(with: alicePeerID))
|
|
let message3 = try #require(
|
|
try alice.processHandshakeMessage(from: receiverPeerID, message: message2)
|
|
)
|
|
_ = try receiver.processHandshakeMessage(from: alicePeerID, message: message3)
|
|
|
|
#expect(alice.hasEstablishedSession(with: receiverPeerID))
|
|
#expect(receiver.hasEstablishedSession(with: alicePeerID))
|
|
let ciphertext = try alice.encrypt(Data("new session".utf8), for: receiverPeerID)
|
|
#expect(try receiver.decrypt(ciphertext, from: alicePeerID) == Data("new session".utf8))
|
|
}
|
|
|
|
@Test("Automatic rekey exposes and completes its exact handshake bytes")
|
|
func automaticRekeyHandshakeIsNotStranded() throws {
|
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
|
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
|
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
|
try establishSessions(alice: alice, bob: bob)
|
|
let originalGeneration = try #require(alice.sessionGeneration(for: bobPeerID))
|
|
var leaseRan = false
|
|
let leased = alice.withCurrentSessionGeneration(
|
|
for: bobPeerID,
|
|
expected: originalGeneration
|
|
) {
|
|
leaseRan = true
|
|
return true
|
|
}
|
|
#expect(leased == true)
|
|
#expect(leaseRan)
|
|
|
|
var emittedPeerID: PeerID?
|
|
var emittedInitiation: NoiseHandshakeInitiation?
|
|
alice.onRekeyHandshakeReady = { peerID, initiation in
|
|
emittedPeerID = peerID
|
|
emittedInitiation = initiation
|
|
}
|
|
try alice._test_initiateAutomaticRekey(for: bobPeerID)
|
|
|
|
#expect(emittedPeerID == bobPeerID)
|
|
#expect(alice.sessionGeneration(for: bobPeerID) == nil)
|
|
leaseRan = false
|
|
let staleLease = alice.withCurrentSessionGeneration(
|
|
for: bobPeerID,
|
|
expected: originalGeneration
|
|
) {
|
|
leaseRan = true
|
|
return true
|
|
}
|
|
#expect(staleLease == nil)
|
|
#expect(!leaseRan)
|
|
let initiation = try #require(emittedInitiation)
|
|
let message1 = try #require(
|
|
alice.claimHandshakeInitiation(initiation, for: bobPeerID)
|
|
)
|
|
#expect(!message1.isEmpty)
|
|
#expect(alice.hasSession(with: bobPeerID))
|
|
#expect(!alice.hasEstablishedSession(with: bobPeerID))
|
|
|
|
let message2 = try #require(
|
|
try bob.processHandshakeMessage(from: alicePeerID, message: message1)
|
|
)
|
|
let message3 = try #require(
|
|
try alice.processHandshakeMessage(from: bobPeerID, message: message2)
|
|
)
|
|
_ = try bob.processHandshakeMessage(from: alicePeerID, message: message3)
|
|
|
|
#expect(alice.hasEstablishedSession(with: bobPeerID))
|
|
#expect(bob.hasEstablishedSession(with: alicePeerID))
|
|
#expect(alice.sessionGeneration(for: bobPeerID) != originalGeneration)
|
|
}
|
|
|
|
@Test("Large private-file payloads use the bounded Noise extension")
|
|
func largePrivateFileNoiseRoundTrip() throws {
|
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
|
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
|
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
|
try establishSessions(alice: alice, bob: bob)
|
|
|
|
let content = Data("%PDF-1.7\n".utf8) + Data(repeating: 0x51, count: 96 * 1024)
|
|
let file = BitchatFilePacket(
|
|
fileName: "large-private.pdf",
|
|
fileSize: UInt64(content.count),
|
|
mimeType: "application/pdf",
|
|
content: content
|
|
)
|
|
let typedPayload = try #require(BLENoisePayloadFactory.privateFile(file))
|
|
#expect(typedPayload.count > NoiseSecurityConstants.maxMessageSize)
|
|
#expect(typedPayload.first == NoisePayloadType.privateFile.rawValue)
|
|
#expect(
|
|
typedPayload.count <= NoiseSecurityConstants.maxPrivateFilePlaintextSize,
|
|
"typedBytes=\(typedPayload.count) limit=\(NoiseSecurityConstants.maxPrivateFilePlaintextSize)"
|
|
)
|
|
|
|
do {
|
|
_ = try alice.encrypt(typedPayload, for: bobPeerID)
|
|
Issue.record("Ordinary Noise payload path must retain its 64 KiB ceiling")
|
|
} catch NoiseSecurityError.messageTooLarge {
|
|
// Expected: only the purpose-specific private-file API may extend it.
|
|
}
|
|
|
|
let ciphertext: Data
|
|
do {
|
|
ciphertext = try alice.encryptPrivateFilePayload(typedPayload, for: bobPeerID)
|
|
} catch {
|
|
Issue.record("Private-file encryption failed: \(error)")
|
|
return
|
|
}
|
|
let decrypted: Data
|
|
do {
|
|
decrypted = try bob.decrypt(ciphertext, from: alicePeerID)
|
|
} catch {
|
|
Issue.record("Private-file decryption failed: \(error); ciphertextBytes=\(ciphertext.count)")
|
|
return
|
|
}
|
|
|
|
#expect(ciphertext.range(of: content) == nil)
|
|
#expect(decrypted == typedPayload)
|
|
}
|
|
|
|
@Test("Concurrent BLE starts preserve one ordinary attempt")
|
|
func duplicateHandshakeIfNeededPreservesFirstAttempt() throws {
|
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
|
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
|
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
|
let starts = HandshakeInitiationRecorder()
|
|
|
|
DispatchQueue.concurrentPerform(iterations: 20) { _ in
|
|
do {
|
|
starts.record(
|
|
try alice.initiateHandshakeIfNeeded(with: bobPeerID)
|
|
)
|
|
} catch {
|
|
starts.record(error: error)
|
|
}
|
|
}
|
|
|
|
#expect(starts.errorCount == 0)
|
|
let attempt = try #require(starts.initiations.first)
|
|
#expect(starts.initiations.count == 1)
|
|
let message1 = try #require(
|
|
alice.claimHandshakeInitiation(attempt, for: bobPeerID)
|
|
)
|
|
let message2 = try #require(
|
|
try bob.processHandshakeMessage(from: alicePeerID, message: message1)
|
|
)
|
|
let message3 = try #require(
|
|
try alice.processHandshakeMessage(from: bobPeerID, message: message2)
|
|
)
|
|
_ = try bob.processHandshakeMessage(from: alicePeerID, message: message3)
|
|
|
|
let ciphertext = try alice.encrypt(
|
|
Data("one atomic start".utf8),
|
|
for: bobPeerID
|
|
)
|
|
#expect(
|
|
try bob.decrypt(ciphertext, from: alicePeerID)
|
|
== Data("one atomic start".utf8)
|
|
)
|
|
}
|
|
|
|
@Test("Restarted peer establishes against a retained ordinary session")
|
|
func restartedPeerCompletesRetainedRemoteRehandshake() throws {
|
|
let aliceKeychain = MockKeychain()
|
|
let alice = NoiseEncryptionService(keychain: aliceKeychain)
|
|
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
|
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
|
try establishSessions(alice: alice, bob: bob)
|
|
|
|
let restartedAlice = NoiseEncryptionService(keychain: aliceKeychain)
|
|
let attempt = try #require(
|
|
try restartedAlice.initiateHandshakeIfNeeded(with: bobPeerID)
|
|
)
|
|
#expect(
|
|
try restartedAlice.initiateHandshakeIfNeeded(with: bobPeerID)
|
|
== nil
|
|
)
|
|
let message1 = try #require(
|
|
restartedAlice.claimHandshakeInitiation(
|
|
attempt,
|
|
for: bobPeerID
|
|
)
|
|
)
|
|
let message2 = try #require(
|
|
try bob.processHandshakeMessage(from: alicePeerID, message: message1)
|
|
)
|
|
let message3 = try #require(
|
|
try restartedAlice.processHandshakeMessage(
|
|
from: bobPeerID,
|
|
message: message2
|
|
)
|
|
)
|
|
_ = try bob.processHandshakeMessage(from: alicePeerID, message: message3)
|
|
|
|
let forward = try restartedAlice.encrypt(
|
|
Data("after restart".utf8),
|
|
for: bobPeerID
|
|
)
|
|
#expect(
|
|
try bob.decrypt(forward, from: alicePeerID)
|
|
== Data("after restart".utf8)
|
|
)
|
|
}
|
|
|
|
@Test("Crossed ordinary initiations choose one deterministic initiator")
|
|
func crossedOrdinaryInitiationsResolveDeterministically() throws {
|
|
let endpoints = orderedServices()
|
|
let lowerAttempt = try #require(
|
|
try endpoints.lower.initiateHandshakeIfNeeded(
|
|
with: endpoints.higherPeerID
|
|
)
|
|
)
|
|
let higherAttempt = try #require(
|
|
try endpoints.higher.initiateHandshakeIfNeeded(
|
|
with: endpoints.lowerPeerID
|
|
)
|
|
)
|
|
let lowerMessage1 = try #require(
|
|
endpoints.lower.claimHandshakeInitiation(
|
|
lowerAttempt,
|
|
for: endpoints.higherPeerID
|
|
)
|
|
)
|
|
let higherMessage1 = try #require(
|
|
endpoints.higher.claimHandshakeInitiation(
|
|
higherAttempt,
|
|
for: endpoints.lowerPeerID
|
|
)
|
|
)
|
|
|
|
#expect(
|
|
try endpoints.lower.processHandshakeMessage(
|
|
from: endpoints.higherPeerID,
|
|
message: higherMessage1
|
|
) == nil
|
|
)
|
|
let message2 = try #require(
|
|
try endpoints.higher.processHandshakeMessage(
|
|
from: endpoints.lowerPeerID,
|
|
message: lowerMessage1
|
|
)
|
|
)
|
|
let message3 = try #require(
|
|
try endpoints.lower.processHandshakeMessage(
|
|
from: endpoints.higherPeerID,
|
|
message: message2
|
|
)
|
|
)
|
|
_ = try endpoints.higher.processHandshakeMessage(
|
|
from: endpoints.lowerPeerID,
|
|
message: message3
|
|
)
|
|
|
|
let ciphertext = try endpoints.lower.encrypt(
|
|
Data("crossed".utf8),
|
|
for: endpoints.higherPeerID
|
|
)
|
|
#expect(
|
|
try endpoints.higher.decrypt(
|
|
ciphertext,
|
|
from: endpoints.lowerPeerID
|
|
) == Data("crossed".utf8)
|
|
)
|
|
}
|
|
|
|
@Test("Delayed losing message one cannot replace the fresh winner")
|
|
func delayedCrossedInitiationIsSuppressed() throws {
|
|
let endpoints = orderedServices()
|
|
let lowerAttempt = try #require(
|
|
try endpoints.lower.initiateHandshakeIfNeeded(
|
|
with: endpoints.higherPeerID
|
|
)
|
|
)
|
|
let higherAttempt = try #require(
|
|
try endpoints.higher.initiateHandshakeIfNeeded(
|
|
with: endpoints.lowerPeerID
|
|
)
|
|
)
|
|
let lowerMessage1 = try #require(
|
|
endpoints.lower.claimHandshakeInitiation(
|
|
lowerAttempt,
|
|
for: endpoints.higherPeerID
|
|
)
|
|
)
|
|
let delayedHigherMessage1 = try #require(
|
|
endpoints.higher.claimHandshakeInitiation(
|
|
higherAttempt,
|
|
for: endpoints.lowerPeerID
|
|
)
|
|
)
|
|
|
|
let message2 = try #require(
|
|
try endpoints.higher.processHandshakeMessage(
|
|
from: endpoints.lowerPeerID,
|
|
message: lowerMessage1
|
|
)
|
|
)
|
|
let message3 = try #require(
|
|
try endpoints.lower.processHandshakeMessage(
|
|
from: endpoints.higherPeerID,
|
|
message: message2
|
|
)
|
|
)
|
|
#expect(
|
|
try endpoints.lower.processHandshakeMessage(
|
|
from: endpoints.higherPeerID,
|
|
message: delayedHigherMessage1
|
|
) == nil
|
|
)
|
|
_ = try endpoints.higher.processHandshakeMessage(
|
|
from: endpoints.lowerPeerID,
|
|
message: message3
|
|
)
|
|
|
|
let ciphertext = try endpoints.higher.encrypt(
|
|
Data("winner intact".utf8),
|
|
for: endpoints.lowerPeerID
|
|
)
|
|
#expect(
|
|
try endpoints.lower.decrypt(
|
|
ciphertext,
|
|
from: endpoints.higherPeerID
|
|
) == Data("winner intact".utf8)
|
|
)
|
|
}
|
|
|
|
@Test("Automatic rekey token dies if an inbound initiation wins first")
|
|
func automaticRekeyClaimsOnlyAtTransportHandoff() throws {
|
|
let endpoints = orderedServices()
|
|
try establishSessions(
|
|
alice: endpoints.lower,
|
|
bob: endpoints.higher
|
|
)
|
|
|
|
var preparedRekey: NoiseHandshakeInitiation?
|
|
endpoints.higher.onRekeyHandshakeReady = { _, initiation in
|
|
preparedRekey = initiation
|
|
}
|
|
try endpoints.higher._test_initiateAutomaticRekey(
|
|
for: endpoints.lowerPeerID
|
|
)
|
|
let staleRekey = try #require(preparedRekey)
|
|
|
|
let winningAttempt = try endpoints.lower.initiateReconnectHandshake(
|
|
with: endpoints.higherPeerID
|
|
)
|
|
let winningMessage1 = try #require(
|
|
endpoints.lower.claimHandshakeInitiation(
|
|
winningAttempt,
|
|
for: endpoints.higherPeerID
|
|
)
|
|
)
|
|
let message2 = try #require(
|
|
try endpoints.higher.processHandshakeMessage(
|
|
from: endpoints.lowerPeerID,
|
|
message: winningMessage1
|
|
)
|
|
)
|
|
#expect(
|
|
endpoints.higher.claimHandshakeInitiation(
|
|
staleRekey,
|
|
for: endpoints.lowerPeerID
|
|
) == nil
|
|
)
|
|
let message3 = try #require(
|
|
try endpoints.lower.processHandshakeMessage(
|
|
from: endpoints.higherPeerID,
|
|
message: message2
|
|
)
|
|
)
|
|
_ = try endpoints.higher.processHandshakeMessage(
|
|
from: endpoints.lowerPeerID,
|
|
message: message3
|
|
)
|
|
#expect(
|
|
endpoints.higher.hasEstablishedSession(
|
|
with: endpoints.lowerPeerID
|
|
)
|
|
)
|
|
}
|
|
|
|
@Test("Ordinary timeout produces exactly one bounded retry")
|
|
func ordinaryInitiationTimeoutIsBounded() async throws {
|
|
let service = NoiseEncryptionService(
|
|
keychain: MockKeychain(),
|
|
ordinaryHandshakeTimeout: 0.03
|
|
)
|
|
let peerID = PeerID(str: "1021324354657687")
|
|
let recorder = HandshakeStartRecorder()
|
|
service.onHandshakeRecoveryRequired = { [weak service] request in
|
|
guard let service else { return }
|
|
do {
|
|
let payload = try claimPreparedRecoveryPayload(
|
|
service,
|
|
request: request
|
|
)
|
|
recorder.recordTimeout()
|
|
recorder.record(message: payload)
|
|
} catch {
|
|
recorder.record(error: error)
|
|
}
|
|
}
|
|
|
|
let first = try #require(
|
|
try service.initiateHandshakeIfNeeded(
|
|
with: peerID,
|
|
retryOnTimeout: true
|
|
)
|
|
)
|
|
#expect(
|
|
service.claimHandshakeInitiation(first, for: peerID) != nil
|
|
)
|
|
let retried = await TestHelpers.waitUntil(
|
|
{ recorder.messages.count == 1 },
|
|
timeout: 1
|
|
)
|
|
#expect(retried)
|
|
let retryExpired = await TestHelpers.waitUntil(
|
|
{ !service.hasSession(with: peerID) },
|
|
timeout: 1
|
|
)
|
|
#expect(retryExpired)
|
|
#expect(recorder.timeoutCount == 1)
|
|
#expect(recorder.errorCount == 0)
|
|
}
|
|
|
|
@Test("Claim gives an attempt a full on-wire timeout window")
|
|
func handshakeClaimRearmsDeadline() async throws {
|
|
let service = NoiseEncryptionService(
|
|
keychain: MockKeychain(),
|
|
ordinaryHandshakeTimeout: 0.08
|
|
)
|
|
let peerID = PeerID(str: "1021324354657687")
|
|
let recorder = HandshakeStartRecorder()
|
|
service.onHandshakeRecoveryRequired = { [weak service] request in
|
|
service?.cancelHandshakeRecovery(request)
|
|
recorder.recordTimeout()
|
|
}
|
|
|
|
let attempt = try #require(
|
|
try service.initiateHandshakeIfNeeded(
|
|
with: peerID,
|
|
retryOnTimeout: true
|
|
)
|
|
)
|
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
|
#expect(
|
|
service.claimHandshakeInitiation(attempt, for: peerID)
|
|
== attempt.payload
|
|
)
|
|
try? await Task.sleep(nanoseconds: 45_000_000)
|
|
#expect(service.hasSession(with: peerID))
|
|
#expect(recorder.timeoutCount == 0)
|
|
let expired = await TestHelpers.waitUntil(
|
|
{ recorder.timeoutCount == 1 },
|
|
timeout: 1
|
|
)
|
|
#expect(expired)
|
|
}
|
|
|
|
@Test("Duplicate spoofed message one cannot extend rollback or repause during cooldown")
|
|
func pacedMessageOneCannotHoldOutboundPaused() async throws {
|
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
|
let bob = NoiseEncryptionService(
|
|
keychain: MockKeychain(),
|
|
ordinaryResponderHandshakeTimeout: 0.06,
|
|
ordinaryReconnectRollbackCooldown: 0.3
|
|
)
|
|
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
|
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
|
let recovery = HandshakeStartRecorder()
|
|
bob.onHandshakeRecoveryRequired = { [weak bob] request in
|
|
bob?.cancelHandshakeRecovery(request)
|
|
recovery.recordTimeout()
|
|
}
|
|
try establishSessions(alice: alice, bob: bob)
|
|
|
|
let spoofedMessage1 = try mallory.initiateHandshake(with: bobPeerID)
|
|
_ = try #require(
|
|
try bob.processHandshakeMessage(
|
|
from: alicePeerID,
|
|
message: spoofedMessage1
|
|
)
|
|
)
|
|
// Exercise replacement before yielding: the test runner may resume a
|
|
// short sleep after the fixed responder deadline under parallel load.
|
|
_ = try #require(
|
|
try bob.processHandshakeMessage(
|
|
from: alicePeerID,
|
|
message: spoofedMessage1
|
|
)
|
|
)
|
|
|
|
let restored = await TestHelpers.waitUntil(
|
|
{ bob.hasEstablishedSession(with: alicePeerID) },
|
|
timeout: 1
|
|
)
|
|
#expect(restored)
|
|
let callbackArrived = await TestHelpers.waitUntil(
|
|
{ recovery.timeoutCount == 1 },
|
|
timeout: 1
|
|
)
|
|
#expect(callbackArrived)
|
|
|
|
// Still inside cooldown: the same unauthenticated initiation is
|
|
// coalesced without removing the restored outbound generation.
|
|
#expect(
|
|
try bob.processHandshakeMessage(
|
|
from: alicePeerID,
|
|
message: spoofedMessage1
|
|
) == nil
|
|
)
|
|
#expect(bob.hasEstablishedSession(with: alicePeerID))
|
|
let ciphertext = try alice.encrypt(
|
|
Data("not repaused".utf8),
|
|
for: bobPeerID
|
|
)
|
|
#expect(
|
|
try bob.decrypt(ciphertext, from: alicePeerID)
|
|
== Data("not repaused".utf8)
|
|
)
|
|
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
#expect(recovery.timeoutCount == 1)
|
|
}
|
|
|
|
@Test("Lost reconnect message three restores then retries once")
|
|
func lostReconnectCompletionGetsOneLocalRetry() async throws {
|
|
let alice = NoiseEncryptionService(
|
|
keychain: MockKeychain(),
|
|
ordinaryHandshakeTimeout: 0.04
|
|
)
|
|
let bob = NoiseEncryptionService(
|
|
keychain: MockKeychain(),
|
|
ordinaryHandshakeTimeout: 0.04,
|
|
ordinaryResponderHandshakeTimeout: 0.04
|
|
)
|
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
|
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
|
try establishSessions(alice: alice, bob: bob)
|
|
alice.clearSession(for: bobPeerID)
|
|
|
|
let recovery = HandshakeStartRecorder()
|
|
bob.onHandshakeRecoveryRequired = { [weak bob] request in
|
|
guard let bob else { return }
|
|
do {
|
|
recovery.recordTimeout()
|
|
recovery.record(
|
|
message: try claimPreparedRecoveryPayload(
|
|
bob,
|
|
request: request
|
|
)
|
|
)
|
|
} catch {
|
|
recovery.record(error: error)
|
|
}
|
|
}
|
|
|
|
let message1 = try alice.initiateHandshake(with: bobPeerID)
|
|
let message2 = try #require(
|
|
try bob.processHandshakeMessage(from: alicePeerID, message: message1)
|
|
)
|
|
_ = try #require(
|
|
try alice.processHandshakeMessage(from: bobPeerID, message: message2)
|
|
)
|
|
// Drop message 3. Bob restores its old receive-only transport and
|
|
// initiates one bounded convergence retry; drop that message 1 too.
|
|
let retryPrepared = await TestHelpers.waitUntil(
|
|
{ recovery.messages.count == 1 },
|
|
timeout: 1
|
|
)
|
|
#expect(retryPrepared)
|
|
let retryExpired = await TestHelpers.waitUntil(
|
|
{ !bob.hasSession(with: alicePeerID) },
|
|
timeout: 1
|
|
)
|
|
#expect(retryExpired)
|
|
#expect(recovery.timeoutCount == 1)
|
|
#expect(recovery.errorCount == 0)
|
|
}
|
|
|
|
@Test("Deterministic responder recovers once from an always-yield peer")
|
|
func yieldedResponderRecoversFromLegacyDoubleYield() async throws {
|
|
let endpoints = orderedServices(
|
|
ordinaryHandshakeTimeout: 0.08,
|
|
ordinaryResponderHandshakeTimeout: 0.08
|
|
)
|
|
let modern = endpoints.higher
|
|
let legacy = endpoints.lower
|
|
let recovery = HandshakeStartRecorder()
|
|
modern.onHandshakeRecoveryRequired = { [weak modern] request in
|
|
guard let modern else { return }
|
|
do {
|
|
recovery.recordTimeout()
|
|
recovery.record(
|
|
message: try claimPreparedRecoveryPayload(
|
|
modern,
|
|
request: request
|
|
)
|
|
)
|
|
} catch {
|
|
recovery.record(error: error)
|
|
}
|
|
}
|
|
|
|
let modernAttempt = try #require(
|
|
try modern.initiateHandshakeIfNeeded(
|
|
with: endpoints.lowerPeerID,
|
|
retryOnTimeout: true
|
|
)
|
|
)
|
|
let legacyAttempt = try #require(
|
|
try legacy.initiateHandshakeIfNeeded(
|
|
with: endpoints.higherPeerID
|
|
)
|
|
)
|
|
let modernMessage1 = try #require(
|
|
modern.claimHandshakeInitiation(
|
|
modernAttempt,
|
|
for: endpoints.lowerPeerID
|
|
)
|
|
)
|
|
let legacyMessage1 = try #require(
|
|
legacy.claimHandshakeInitiation(
|
|
legacyAttempt,
|
|
for: endpoints.higherPeerID
|
|
)
|
|
)
|
|
|
|
let modernMessage2 = try #require(
|
|
try modern.processHandshakeMessage(
|
|
from: endpoints.lowerPeerID,
|
|
message: legacyMessage1
|
|
)
|
|
)
|
|
// Released peers yielded regardless of ID. Clearing their local
|
|
// initiator reproduces that role choice without changing wire bytes.
|
|
legacy.clearSession(for: endpoints.higherPeerID)
|
|
let legacyMessage2 = try #require(
|
|
try legacy.processHandshakeMessage(
|
|
from: endpoints.higherPeerID,
|
|
message: modernMessage1
|
|
)
|
|
)
|
|
|
|
do {
|
|
_ = try modern.processHandshakeMessage(
|
|
from: endpoints.lowerPeerID,
|
|
message: legacyMessage2
|
|
)
|
|
Issue.record("Expected the crossed responder message to fail")
|
|
} catch is NoiseManagedHandshakeFailure {
|
|
// The manager owns the single retry.
|
|
} catch {
|
|
Issue.record("Unexpected managed failure: \(error)")
|
|
}
|
|
do {
|
|
_ = try legacy.processHandshakeMessage(
|
|
from: endpoints.higherPeerID,
|
|
message: modernMessage2
|
|
)
|
|
Issue.record("Expected the legacy responder message to fail")
|
|
} catch {
|
|
// Expected; this side did not own retry intent.
|
|
}
|
|
|
|
let didRecover = await TestHelpers.waitUntil(
|
|
{ recovery.messages.count == 1 },
|
|
timeout: 1
|
|
)
|
|
#expect(didRecover)
|
|
let retryMessage1 = try #require(recovery.messages.first)
|
|
let retryMessage2 = try #require(
|
|
try legacy.processHandshakeMessage(
|
|
from: endpoints.higherPeerID,
|
|
message: retryMessage1
|
|
)
|
|
)
|
|
let retryMessage3 = try #require(
|
|
try modern.processHandshakeMessage(
|
|
from: endpoints.lowerPeerID,
|
|
message: retryMessage2
|
|
)
|
|
)
|
|
_ = try legacy.processHandshakeMessage(
|
|
from: endpoints.higherPeerID,
|
|
message: retryMessage3
|
|
)
|
|
try? await Task.sleep(nanoseconds: 120_000_000)
|
|
#expect(recovery.timeoutCount == 1)
|
|
#expect(recovery.errorCount == 0)
|
|
let ciphertext = try modern.encrypt(
|
|
Data("legacy converged".utf8),
|
|
for: endpoints.lowerPeerID
|
|
)
|
|
#expect(
|
|
try legacy.decrypt(
|
|
ciphertext,
|
|
from: endpoints.higherPeerID
|
|
) == Data("legacy converged".utf8)
|
|
)
|
|
}
|
|
|
|
@Test("Immediate legacy restart during completion grace converges once")
|
|
func immediateLegacyRestartDuringCompletionGrace() async throws {
|
|
let firstKeychain = MockKeychain()
|
|
let secondKeychain = MockKeychain()
|
|
let first = NoiseEncryptionService(
|
|
keychain: firstKeychain,
|
|
recentInitiatorCompletionGracePeriod: 0.03
|
|
)
|
|
let second = NoiseEncryptionService(
|
|
keychain: secondKeychain,
|
|
recentInitiatorCompletionGracePeriod: 0.03
|
|
)
|
|
let firstPeerID = PeerID(publicKey: first.getStaticPublicKeyData())
|
|
let secondPeerID = PeerID(publicKey: second.getStaticPublicKeyData())
|
|
|
|
let lower: NoiseEncryptionService
|
|
let higher: NoiseEncryptionService
|
|
let higherKeychain: MockKeychain
|
|
let lowerPeerID: PeerID
|
|
let higherPeerID: PeerID
|
|
if firstPeerID < secondPeerID {
|
|
lower = first
|
|
lowerPeerID = firstPeerID
|
|
higher = second
|
|
higherKeychain = secondKeychain
|
|
higherPeerID = secondPeerID
|
|
} else {
|
|
lower = second
|
|
lowerPeerID = secondPeerID
|
|
higher = first
|
|
higherKeychain = firstKeychain
|
|
higherPeerID = firstPeerID
|
|
}
|
|
try establishSessions(alice: lower, bob: higher)
|
|
|
|
let restartedHigher = NoiseEncryptionService(keychain: higherKeychain)
|
|
let recovery = HandshakeStartRecorder()
|
|
lower.onHandshakeRecoveryRequired = { [weak lower] request in
|
|
guard let lower else { return }
|
|
do {
|
|
recovery.recordTimeout()
|
|
recovery.record(
|
|
message: try claimPreparedRecoveryPayload(
|
|
lower,
|
|
request: request
|
|
)
|
|
)
|
|
} catch {
|
|
recovery.record(error: error)
|
|
}
|
|
}
|
|
|
|
let restartAttempt = try #require(
|
|
try restartedHigher.initiateHandshakeIfNeeded(with: lowerPeerID)
|
|
)
|
|
let restartMessage1 = try #require(
|
|
restartedHigher.claimHandshakeInitiation(
|
|
restartAttempt,
|
|
for: lowerPeerID
|
|
)
|
|
)
|
|
#expect(
|
|
try lower.processHandshakeMessage(
|
|
from: higherPeerID,
|
|
message: restartMessage1
|
|
) == nil
|
|
)
|
|
#expect(
|
|
try lower.processHandshakeMessage(
|
|
from: higherPeerID,
|
|
message: restartMessage1
|
|
) == nil
|
|
)
|
|
#expect(lower.hasEstablishedSession(with: higherPeerID))
|
|
|
|
let requested = await TestHelpers.waitUntil(
|
|
{ recovery.messages.count == 1 },
|
|
timeout: 1
|
|
)
|
|
#expect(requested)
|
|
let retryMessage1 = try #require(recovery.messages.first)
|
|
let retryMessage2 = try #require(
|
|
try restartedHigher.processHandshakeMessage(
|
|
from: lowerPeerID,
|
|
message: retryMessage1
|
|
)
|
|
)
|
|
let retryMessage3 = try #require(
|
|
try lower.processHandshakeMessage(
|
|
from: higherPeerID,
|
|
message: retryMessage2
|
|
)
|
|
)
|
|
_ = try restartedHigher.processHandshakeMessage(
|
|
from: lowerPeerID,
|
|
message: retryMessage3
|
|
)
|
|
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
#expect(recovery.timeoutCount == 1)
|
|
#expect(recovery.errorCount == 0)
|
|
let ciphertext = try restartedHigher.encrypt(
|
|
Data("restart converged".utf8),
|
|
for: lowerPeerID
|
|
)
|
|
#expect(
|
|
try lower.decrypt(ciphertext, from: higherPeerID)
|
|
== Data("restart converged".utf8)
|
|
)
|
|
}
|
|
|
|
@Test("Atomic reconnect retires old sending keys before message one")
|
|
func atomicReconnectQueuesUntilOrdinaryHandshakeCompletes() throws {
|
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
|
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
|
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
|
|
|
try establishSessions(alice: alice, bob: bob)
|
|
let oldCiphertext = try alice.encrypt(Data("old transport".utf8), for: bobPeerID)
|
|
#expect(try bob.decrypt(oldCiphertext, from: alicePeerID) == Data("old transport".utf8))
|
|
|
|
let initiation = try alice.initiateReconnectHandshake(with: bobPeerID)
|
|
#expect(alice.hasSession(with: bobPeerID))
|
|
#expect(!alice.hasEstablishedSession(with: bobPeerID))
|
|
do {
|
|
_ = try alice.encrypt(Data("must queue".utf8), for: bobPeerID)
|
|
Issue.record("Expected encryption to wait for the reconnect")
|
|
} catch NoiseEncryptionError.handshakeRequired {
|
|
// Expected: BLE queues behind the ordinary handshaking session.
|
|
} catch {
|
|
Issue.record("Unexpected in-window encryption error: \(error)")
|
|
}
|
|
|
|
let message1 = try #require(
|
|
alice.claimHandshakeInitiation(initiation, for: bobPeerID)
|
|
)
|
|
bob.clearSession(for: alicePeerID)
|
|
let message2 = try #require(
|
|
try bob.processHandshakeMessage(from: alicePeerID, message: message1)
|
|
)
|
|
let message3 = try #require(
|
|
try alice.processHandshakeMessage(from: bobPeerID, message: message2)
|
|
)
|
|
_ = try bob.processHandshakeMessage(from: alicePeerID, message: message3)
|
|
|
|
let fresh = try alice.encrypt(Data("fresh transport".utf8), for: bobPeerID)
|
|
#expect(try bob.decrypt(fresh, from: alicePeerID) == Data("fresh transport".utf8))
|
|
}
|
|
|
|
@Test("Inbound reconnect quarantines old sending keys until identity proof")
|
|
func inboundReconnectQuarantinesOldTransport() throws {
|
|
let restarted = NoiseEncryptionService(keychain: MockKeychain())
|
|
let retained = NoiseEncryptionService(keychain: MockKeychain())
|
|
let restartedPeerID = PeerID(publicKey: restarted.getStaticPublicKeyData())
|
|
let retainedPeerID = PeerID(publicKey: retained.getStaticPublicKeyData())
|
|
|
|
try establishSessions(alice: restarted, bob: retained)
|
|
let inFlightOldCiphertext = try restarted.encrypt(
|
|
Data("old receive-only transport".utf8),
|
|
for: retainedPeerID
|
|
)
|
|
restarted.clearSession(for: retainedPeerID)
|
|
|
|
let message1 = try restarted.initiateHandshake(with: retainedPeerID)
|
|
let message2 = try #require(
|
|
try retained.processHandshakeMessage(
|
|
from: restartedPeerID,
|
|
message: message1
|
|
)
|
|
)
|
|
#expect(!retained.hasEstablishedSession(with: restartedPeerID))
|
|
#expect(
|
|
try retained.decrypt(
|
|
inFlightOldCiphertext,
|
|
from: restartedPeerID
|
|
) == Data("old receive-only transport".utf8)
|
|
)
|
|
do {
|
|
_ = try retained.encrypt(
|
|
Data("must queue at responder".utf8),
|
|
for: restartedPeerID
|
|
)
|
|
Issue.record("Expected quarantined responder encryption to wait")
|
|
} catch NoiseEncryptionError.handshakeRequired {
|
|
// Expected.
|
|
} catch {
|
|
Issue.record("Unexpected quarantine encryption error: \(error)")
|
|
}
|
|
|
|
let message3 = try #require(
|
|
try restarted.processHandshakeMessage(
|
|
from: retainedPeerID,
|
|
message: message2
|
|
)
|
|
)
|
|
_ = try retained.processHandshakeMessage(
|
|
from: restartedPeerID,
|
|
message: message3
|
|
)
|
|
|
|
let fresh = try retained.encrypt(
|
|
Data("identity proved".utf8),
|
|
for: restartedPeerID
|
|
)
|
|
#expect(
|
|
try restarted.decrypt(fresh, from: retainedPeerID)
|
|
== Data("identity proved".utf8)
|
|
)
|
|
}
|
|
|
|
@Test("Malformed handshake bytes cannot tear down an established session")
|
|
func establishedSessionIgnoresNonInitialHandshakeGarbage() throws {
|
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
|
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
|
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
|
try establishSessions(alice: alice, bob: bob)
|
|
|
|
#expect(
|
|
try bob.processHandshakeMessage(
|
|
from: alicePeerID,
|
|
message: Data(repeating: 0xA5, count: 31)
|
|
) == nil
|
|
)
|
|
#expect(bob.hasEstablishedSession(with: alicePeerID))
|
|
let ciphertext = try alice.encrypt(
|
|
Data("session survived".utf8),
|
|
for: bobPeerID
|
|
)
|
|
#expect(
|
|
try bob.decrypt(ciphertext, from: alicePeerID)
|
|
== Data("session survived".utf8)
|
|
)
|
|
}
|
|
|
|
@Test("Forged reconnect restores the quarantined transport")
|
|
func forgedReconnectRestoresQuarantinedTransport() throws {
|
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
|
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
|
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
|
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
|
|
|
try establishSessions(alice: alice, bob: bob)
|
|
|
|
let forgedMessage1 = try mallory.initiateHandshake(with: bobPeerID)
|
|
let forgedMessage2 = try #require(
|
|
try bob.processHandshakeMessage(
|
|
from: alicePeerID,
|
|
message: forgedMessage1
|
|
)
|
|
)
|
|
#expect(!bob.hasEstablishedSession(with: alicePeerID))
|
|
let forgedMessage3 = try #require(
|
|
try mallory.processHandshakeMessage(
|
|
from: bobPeerID,
|
|
message: forgedMessage2
|
|
)
|
|
)
|
|
|
|
do {
|
|
_ = try bob.processHandshakeMessage(
|
|
from: alicePeerID,
|
|
message: forgedMessage3
|
|
)
|
|
Issue.record("Expected forged static identity to be rejected")
|
|
} catch NoiseSessionError.peerIdentityMismatch {
|
|
// Expected; the manager restores the quarantined transport.
|
|
} catch {
|
|
Issue.record("Unexpected forged reconnect error: \(error)")
|
|
}
|
|
|
|
#expect(bob.hasEstablishedSession(with: alicePeerID))
|
|
let oldTransport = try alice.encrypt(
|
|
Data("rollback survived".utf8),
|
|
for: bobPeerID
|
|
)
|
|
#expect(
|
|
try bob.decrypt(oldTransport, from: alicePeerID)
|
|
== Data("rollback survived".utf8)
|
|
)
|
|
}
|
|
|
|
@Test("Lost reconnect completion restores the quarantined transport")
|
|
func timedOutReconnectRestoresQuarantinedTransport() async throws {
|
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
|
let bob = NoiseEncryptionService(
|
|
keychain: MockKeychain(),
|
|
ordinaryResponderHandshakeTimeout: 0.02
|
|
)
|
|
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
|
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
|
|
|
try establishSessions(alice: alice, bob: bob)
|
|
let forgedMessage1 = try mallory.initiateHandshake(with: bobPeerID)
|
|
_ = try #require(
|
|
try bob.processHandshakeMessage(
|
|
from: alicePeerID,
|
|
message: forgedMessage1
|
|
)
|
|
)
|
|
#expect(!bob.hasEstablishedSession(with: alicePeerID))
|
|
|
|
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
|
|
#expect(bob.hasEstablishedSession(with: alicePeerID))
|
|
let oldTransport = try alice.encrypt(
|
|
Data("timeout rollback".utf8),
|
|
for: bobPeerID
|
|
)
|
|
#expect(
|
|
try bob.decrypt(oldTransport, from: alicePeerID)
|
|
== Data("timeout rollback".utf8)
|
|
)
|
|
}
|
|
|
|
@Test("Failed reconnect authorization preserves the established transport")
|
|
func failedReconnectAuthorizationPreservesSession() throws {
|
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
|
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
|
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
|
|
|
try establishSessions(alice: alice, bob: bob)
|
|
// The initiator exchange consumed two authorizations for this peer.
|
|
for _ in 2..<NoiseSecurityConstants.maxHandshakesPerMinute {
|
|
do {
|
|
_ = try alice.initiateHandshake(with: bobPeerID)
|
|
Issue.record("Expected the established-session guard")
|
|
} catch NoiseSessionError.alreadyEstablished {
|
|
// Expected.
|
|
}
|
|
}
|
|
|
|
do {
|
|
_ = try alice.initiateReconnectHandshake(with: bobPeerID)
|
|
Issue.record("Expected reconnect authorization to be rate limited")
|
|
} catch NoiseSecurityError.rateLimitExceeded {
|
|
// Expected before the working session moves.
|
|
} catch {
|
|
Issue.record("Unexpected reconnect authorization error: \(error)")
|
|
}
|
|
|
|
#expect(alice.hasEstablishedSession(with: bobPeerID))
|
|
let ciphertext = try alice.encrypt(
|
|
Data("still established".utf8),
|
|
for: bobPeerID
|
|
)
|
|
#expect(
|
|
try bob.decrypt(ciphertext, from: alicePeerID)
|
|
== Data("still established".utf8)
|
|
)
|
|
}
|
|
|
|
@Test("Encrypt without a session requests handshake and decrypt without session fails")
|
|
func handshakeRequiredAndSessionNotEstablishedErrors() throws {
|
|
let service = NoiseEncryptionService(keychain: MockKeychain())
|
|
let peerID = PeerID(str: "1021324354657687")
|
|
var requestedPeerID: PeerID?
|
|
|
|
service.onHandshakeRequired = { requestedPeerID = $0 }
|
|
|
|
do {
|
|
_ = try service.encrypt(Data("hello".utf8), for: peerID)
|
|
Issue.record("Expected handshakeRequired error")
|
|
} catch NoiseEncryptionError.handshakeRequired {
|
|
#expect(requestedPeerID == peerID)
|
|
} catch {
|
|
Issue.record("Unexpected error: \(error)")
|
|
}
|
|
|
|
do {
|
|
_ = try service.decrypt(Data("hello".utf8), from: peerID)
|
|
Issue.record("Expected sessionNotEstablished error")
|
|
} catch NoiseEncryptionError.sessionNotEstablished {
|
|
// Expected
|
|
} catch {
|
|
Issue.record("Unexpected error: \(error)")
|
|
}
|
|
}
|
|
|
|
@Test("Clearing persistent identity removes saved keys")
|
|
func clearPersistentIdentityRemovesSavedKeys() {
|
|
let keychain = MockKeychain()
|
|
let service = NoiseEncryptionService(keychain: keychain)
|
|
|
|
#expect(service.getStaticPublicKeyData().count == 32)
|
|
#expect(service.getSigningPublicKeyData().count == 32)
|
|
|
|
service.clearPersistentIdentity()
|
|
|
|
if case .itemNotFound = keychain.getIdentityKeyWithResult(forKey: "noiseStaticKey") {
|
|
} else {
|
|
Issue.record("Expected noiseStaticKey to be removed")
|
|
}
|
|
|
|
if case .itemNotFound = keychain.getIdentityKeyWithResult(forKey: "ed25519SigningKey") {
|
|
} else {
|
|
Issue.record("Expected ed25519SigningKey to be removed")
|
|
}
|
|
}
|
|
|
|
@Test("Responder completion state spans ordinary XX message three")
|
|
func responderCompletionStateTracksOrdinaryHandshake() throws {
|
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
|
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
|
let alicePeerID = PeerID(
|
|
publicKey: alice.getStaticPublicKeyData()
|
|
)
|
|
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
|
|
|
let message1 = try alice.initiateHandshake(with: bobPeerID)
|
|
#expect(
|
|
!bob.isAwaitingResponderHandshakeCompletion(with: alicePeerID)
|
|
)
|
|
|
|
let message2 = try #require(
|
|
try bob.processHandshakeMessage(
|
|
from: alicePeerID,
|
|
message: message1
|
|
)
|
|
)
|
|
#expect(
|
|
bob.isAwaitingResponderHandshakeCompletion(with: alicePeerID)
|
|
)
|
|
|
|
let message3 = try #require(
|
|
try alice.processHandshakeMessage(
|
|
from: bobPeerID,
|
|
message: message2
|
|
)
|
|
)
|
|
#expect(
|
|
bob.isAwaitingResponderHandshakeCompletion(with: alicePeerID)
|
|
)
|
|
|
|
_ = try bob.processHandshakeMessage(
|
|
from: alicePeerID,
|
|
message: message3
|
|
)
|
|
#expect(
|
|
!bob.isAwaitingResponderHandshakeCompletion(with: alicePeerID)
|
|
)
|
|
}
|
|
|
|
@Test("Transport readiness rejection spends no message budget")
|
|
func transportReadinessRejectionSpendsNoMessageBudget() throws {
|
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
|
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
|
let alicePeerID = PeerID(
|
|
publicKey: alice.getStaticPublicKeyData()
|
|
)
|
|
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
|
try establishSessions(alice: alice, bob: bob)
|
|
let plaintext = Data([
|
|
NoisePayloadType.privateMessage.rawValue,
|
|
0xAB, 0xCD
|
|
])
|
|
let ciphertext = try alice.encrypt(plaintext, for: bobPeerID)
|
|
|
|
for _ in 0...NoiseSecurityConstants.maxMessagesPerSecond {
|
|
do {
|
|
_ = try bob.decryptWithSessionGeneration(
|
|
ciphertext,
|
|
from: alicePeerID,
|
|
establishedGenerationIsReady: { _ in false }
|
|
)
|
|
Issue.record(
|
|
"Expected transport generation readiness rejection"
|
|
)
|
|
} catch NoiseEncryptionError.transportGenerationNotReady {
|
|
// Expected: authorization and nonce mutation are both later.
|
|
} catch {
|
|
Issue.record("Unexpected readiness error: \(error)")
|
|
}
|
|
}
|
|
|
|
let decrypted = try bob.decryptWithSessionGeneration(
|
|
ciphertext,
|
|
from: alicePeerID,
|
|
establishedGenerationIsReady: { _ in true }
|
|
)
|
|
#expect(decrypted.plaintext == plaintext)
|
|
}
|
|
|
|
@Test("NoiseMessage JSON and binary encoding round-trip")
|
|
func noiseMessageRoundTrips() throws {
|
|
let message = NoiseMessage(
|
|
type: .encryptedMessage,
|
|
sessionID: UUID().uuidString,
|
|
payload: Data([1, 2, 3, 4])
|
|
)
|
|
|
|
let encoded = try #require(message.encode(), "Expected JSON encoding")
|
|
let decoded = try #require(NoiseMessage.decode(from: encoded), "Expected JSON decode")
|
|
#expect(decoded.type == message.type)
|
|
#expect(decoded.sessionID == message.sessionID)
|
|
#expect(decoded.payload == message.payload)
|
|
|
|
#expect(NoiseMessage.decodeWithError(from: Data("bad".utf8)) == nil)
|
|
|
|
let binary = message.toBinaryData()
|
|
let roundTripped = try #require(NoiseMessage.fromBinaryData(binary), "Expected binary decode")
|
|
#expect(roundTripped.type == message.type)
|
|
#expect(roundTripped.sessionID == message.sessionID)
|
|
#expect(roundTripped.payload == message.payload)
|
|
#expect(NoiseMessage.fromBinaryData(Data()) == nil)
|
|
}
|
|
|
|
private func establishSessions(
|
|
alice: NoiseEncryptionService,
|
|
bob: NoiseEncryptionService
|
|
) throws {
|
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
|
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
|
let message1 = try alice.initiateHandshake(with: bobPeerID)
|
|
let response = try bob.processHandshakeMessage(from: alicePeerID, message: message1)
|
|
let message2 = try #require(response, "Expected handshake response")
|
|
let final = try alice.processHandshakeMessage(from: bobPeerID, message: message2)
|
|
let message3 = try #require(final, "Expected handshake final")
|
|
let finalMessage = try bob.processHandshakeMessage(from: alicePeerID, message: message3)
|
|
#expect(finalMessage == nil)
|
|
}
|
|
}
|
|
|
|
private func orderedServices(
|
|
ordinaryHandshakeTimeout: TimeInterval =
|
|
NoiseSecurityConstants.ordinaryHandshakeTimeout,
|
|
ordinaryResponderHandshakeTimeout: TimeInterval =
|
|
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
|
|
) -> (
|
|
lower: NoiseEncryptionService,
|
|
lowerPeerID: PeerID,
|
|
higher: NoiseEncryptionService,
|
|
higherPeerID: PeerID
|
|
) {
|
|
let first = NoiseEncryptionService(
|
|
keychain: MockKeychain(),
|
|
ordinaryHandshakeTimeout: ordinaryHandshakeTimeout,
|
|
ordinaryResponderHandshakeTimeout:
|
|
ordinaryResponderHandshakeTimeout
|
|
)
|
|
let second = NoiseEncryptionService(
|
|
keychain: MockKeychain(),
|
|
ordinaryHandshakeTimeout: ordinaryHandshakeTimeout,
|
|
ordinaryResponderHandshakeTimeout:
|
|
ordinaryResponderHandshakeTimeout
|
|
)
|
|
let firstPeerID = PeerID(publicKey: first.getStaticPublicKeyData())
|
|
let secondPeerID = PeerID(publicKey: second.getStaticPublicKeyData())
|
|
if firstPeerID < secondPeerID {
|
|
return (first, firstPeerID, second, secondPeerID)
|
|
}
|
|
return (second, secondPeerID, first, firstPeerID)
|
|
}
|
|
|
|
private func claimPreparedRecoveryPayload(
|
|
_ service: NoiseEncryptionService,
|
|
request: NoiseHandshakeRecoveryRequest
|
|
) throws -> Data? {
|
|
guard let preparation =
|
|
try service.prepareHandshakeRecovery(request) else {
|
|
return nil
|
|
}
|
|
switch preparation {
|
|
case .ordinary(let initiation):
|
|
return service.claimHandshakeInitiation(
|
|
initiation,
|
|
for: request.peerID
|
|
)
|
|
case .transferred:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private final class AuthenticationRecorder: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var entries: [(PeerID, String)] = []
|
|
private var generationEntries: [(PeerID, UUID)] = []
|
|
|
|
var count: Int {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
return entries.count
|
|
}
|
|
|
|
var generationCount: Int {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
return generationEntries.count
|
|
}
|
|
|
|
func record(peerID: PeerID, fingerprint: String) {
|
|
lock.lock()
|
|
entries.append((peerID, fingerprint))
|
|
lock.unlock()
|
|
}
|
|
|
|
func record(peerID: PeerID, fingerprint _: String, sessionGeneration: UUID) {
|
|
lock.lock()
|
|
generationEntries.append((peerID, sessionGeneration))
|
|
lock.unlock()
|
|
}
|
|
|
|
func generation(for peerID: PeerID) -> UUID? {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
return generationEntries.last { $0.0 == peerID }?.1
|
|
}
|
|
}
|
|
|
|
private final class HandshakeInitiationRecorder: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var storedInitiations: [NoiseHandshakeInitiation] = []
|
|
private var storedErrorCount = 0
|
|
|
|
var initiations: [NoiseHandshakeInitiation] {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
return storedInitiations
|
|
}
|
|
|
|
var errorCount: Int {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
return storedErrorCount
|
|
}
|
|
|
|
func record(_ initiation: NoiseHandshakeInitiation?) {
|
|
guard let initiation else { return }
|
|
lock.lock()
|
|
storedInitiations.append(initiation)
|
|
lock.unlock()
|
|
}
|
|
|
|
func record(error _: Error) {
|
|
lock.lock()
|
|
storedErrorCount += 1
|
|
lock.unlock()
|
|
}
|
|
}
|
|
|
|
private final class HandshakeStartRecorder: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var storedMessages: [Data] = []
|
|
private var storedErrorCount = 0
|
|
private var storedTimeoutCount = 0
|
|
|
|
var messages: [Data] {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
return storedMessages
|
|
}
|
|
|
|
var errorCount: Int {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
return storedErrorCount
|
|
}
|
|
|
|
var timeoutCount: Int {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
return storedTimeoutCount
|
|
}
|
|
|
|
func record(message: Data?) {
|
|
guard let message else { return }
|
|
lock.lock()
|
|
storedMessages.append(message)
|
|
lock.unlock()
|
|
}
|
|
|
|
func record(error _: Error) {
|
|
lock.lock()
|
|
storedErrorCount += 1
|
|
lock.unlock()
|
|
}
|
|
|
|
func recordTimeout() {
|
|
lock.lock()
|
|
storedTimeoutCount += 1
|
|
lock.unlock()
|
|
}
|
|
}
|