Bind Noise sessions to claimed peer identities; require signed leaves (#1432)

Adds remote-static-key->peerID binding at Noise handshake completion (closes a mesh impersonation/MITM hole where a peer could complete a handshake under another peer's ID). Also hardens LEAVE handling to require a verified signature and suppresses relay of unverifiable leaves.
This commit is contained in:
jack
2026-07-26 10:30:55 +02:00
committed by GitHub
parent cd727c6867
commit 16324c819f
10 changed files with 637 additions and 76 deletions
@@ -14,6 +14,9 @@ enum NoiseSecurityConstants {
// Maximum handshake message size
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
// Noise XX message 1 contains only the initiator's 32-byte ephemeral key.
static let xxInitialMessageSize = 32
// Session timeout - sessions older than this should be renegotiated
static let sessionTimeout: TimeInterval = 86400 // 24 hours
+1
View File
@@ -11,4 +11,5 @@ enum NoiseSessionError: Error, Equatable {
case notEstablished
case sessionNotFound
case alreadyEstablished
case peerIdentityMismatch
}
+128 -36
View File
@@ -11,8 +11,18 @@ import CryptoKit
import Foundation
import BitFoundation
struct NoiseHandshakeProcessingResult {
let response: Data?
let didEstablishAuthenticatedSession: Bool
}
final class NoiseSessionManager {
private var sessions: [PeerID: NoiseSession] = [:]
/// A responder rehandshake must not evict a working transport session
/// before the candidate proves that its authenticated static key belongs
/// to the claimed wire ID. Candidates therefore live outside `sessions`
/// until the XX handshake completes and the binding is validated.
private var responderCandidates: [PeerID: NoiseSession] = [:]
private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
@@ -54,6 +64,9 @@ final class NoiseSessionManager {
if let session = sessions.removeValue(forKey: peerID) {
session.reset() // Clear sensitive data before removing
}
if let candidate = responderCandidates.removeValue(forKey: peerID) {
candidate.reset()
}
}
}
@@ -62,7 +75,11 @@ final class NoiseSessionManager {
for (_, session) in sessions {
session.reset()
}
for (_, candidate) in responderCandidates {
candidate.reset()
}
sessions.removeAll()
responderCandidates.removeAll()
}
}
@@ -79,6 +96,7 @@ final class NoiseSessionManager {
// Remove any existing non-established session
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
_ = sessions.removeValue(forKey: peerID)
existingSession.reset()
}
// Create new initiator session
@@ -91,6 +109,7 @@ final class NoiseSessionManager {
} catch {
// Clean up failed session
_ = sessions.removeValue(forKey: peerID)
session.reset()
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
throw error
}
@@ -98,61 +117,116 @@ final class NoiseSessionManager {
}
func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
try handleIncomingHandshakeWithResult(
from: peerID,
message: message
).response
}
/// Processes one exact handshake candidate and reports whether that
/// candidate completed authenticated establishment. The peer's retained
/// session may already be established while a replacement is only on
/// message one, so callers must not infer candidate completion from the
/// peer-level session table.
func handleIncomingHandshakeWithResult(
from peerID: PeerID,
message: Data
) throws -> NoiseHandshakeProcessingResult {
// Process everything within the synchronized block to prevent race conditions
return try managerQueue.sync(flags: .barrier) {
var shouldCreateNew = false
var existingSession: NoiseSession? = nil
if let existing = sessions[peerID] {
// If we have an established session, the peer must have cleared their session
// for a good reason (e.g., decryption failure, restart, etc.)
// We should accept the new handshake to re-establish encryption
if existing.isEstablished() {
SecureLogger.info("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session", category: .session)
_ = sessions.removeValue(forKey: peerID)
shouldCreateNew = true
let session: NoiseSession
let isReplacementCandidate: Bool
if let candidate = responderCandidates[peerID] {
// A fresh XX message 1 supersedes an incomplete candidate,
// but never the established session it is trying to replace.
if message.count == NoiseSecurityConstants.xxInitialMessageSize {
candidate.reset()
let replacement = sessionFactory(peerID, .responder)
responderCandidates[peerID] = replacement
session = replacement
} else {
// If we're in the middle of a handshake and receive a new initiation,
// reset and start fresh (the other side may have restarted)
if existing.getState() == .handshaking && message.count == 32 {
_ = sessions.removeValue(forKey: peerID)
shouldCreateNew = true
} else {
existingSession = existing
}
session = candidate
}
isReplacementCandidate = true
} else if let existing = sessions[peerID] {
if existing.isEstablished() {
SecureLogger.info(
"Validating replacement handshake from \(peerID) while preserving the established session",
category: .session
)
let candidate = sessionFactory(peerID, .responder)
responderCandidates[peerID] = candidate
session = candidate
isReplacementCandidate = true
} else if existing.getState() == .handshaking,
message.count == NoiseSecurityConstants.xxInitialMessageSize {
// No established transport state exists to preserve. A
// fresh initiation replaces the incomplete handshake.
_ = sessions.removeValue(forKey: peerID)
existing.reset()
let replacement = sessionFactory(peerID, .responder)
sessions[peerID] = replacement
session = replacement
isReplacementCandidate = false
} else {
session = existing
isReplacementCandidate = false
}
} else {
shouldCreateNew = true
}
// Get or create session
let session: NoiseSession
if shouldCreateNew {
let newSession = sessionFactory(peerID, .responder)
sessions[peerID] = newSession
session = newSession
} else {
session = existingSession!
isReplacementCandidate = false
}
// Process the handshake message within the synchronized block
do {
let response = try session.processHandshakeMessage(message)
// Check if session is established after processing
if session.isEstablished() {
if let remoteKey = session.getRemoteStaticPublicKey() {
// Schedule callback outside the synchronized block to prevent deadlock
DispatchQueue.global().async { [weak self] in
self?.onSessionEstablished?(peerID, remoteKey)
// Check the exact session that processed this message. A
// preserved peer-level session can remain established while a
// replacement candidate is still unauthenticated.
let didEstablishAuthenticatedSession = session.isEstablished()
if didEstablishAuthenticatedSession {
guard let remoteKey = session.getRemoteStaticPublicKey(),
authenticatedRemoteKey(remoteKey, matches: peerID) else {
throw NoiseSessionError.peerIdentityMismatch
}
if isReplacementCandidate {
_ = responderCandidates.removeValue(forKey: peerID)
let previous = sessions.updateValue(session, forKey: peerID)
if let previous, previous !== session {
previous.reset()
}
}
// Schedule callback outside the synchronized block to prevent deadlock
DispatchQueue.global().async { [weak self] in
self?.onSessionEstablished?(peerID, remoteKey)
}
}
return response
return NoiseHandshakeProcessingResult(
response: response,
didEstablishAuthenticatedSession:
didEstablishAuthenticatedSession
)
} catch {
// Reset the session on handshake failure so next attempt can start fresh
_ = sessions.removeValue(forKey: peerID)
// A failed candidate is discarded without touching the
// established session. Ordinary failed handshakes retain the
// historical cleanup behavior.
if isReplacementCandidate {
if let storedCandidate = responderCandidates[peerID],
storedCandidate === session {
_ = responderCandidates.removeValue(forKey: peerID)
}
} else if let storedSession = sessions[peerID],
storedSession === session {
_ = sessions.removeValue(forKey: peerID)
}
session.reset()
// Schedule callback outside the synchronized block to prevent deadlock
DispatchQueue.global().async { [weak self] in
@@ -164,6 +238,24 @@ final class NoiseSessionManager {
}
}
}
/// Mesh handshakes normally use a 16-hex wire ID. Full Noise-key IDs are
/// also accepted by internal callers when they exactly match the static
/// key. Non-wire identifiers remain available to protocol test harnesses;
/// BLE packet ingress always supplies a short hexadecimal ID.
private func authenticatedRemoteKey(
_ remoteKey: Curve25519.KeyAgreement.PublicKey,
matches claimedPeerID: PeerID
) -> Bool {
let rawKey = remoteKey.rawRepresentation
if claimedPeerID.isShort {
return PeerID(publicKey: rawKey) == claimedPeerID
}
if let claimedNoiseKey = claimedPeerID.noiseKey {
return claimedNoiseKey == rawKey
}
return true
}
// MARK: - Encryption/Decryption
@@ -2,6 +2,11 @@ import BitFoundation
import BitLogger
import Foundation
struct BLENoiseHandshakeHandlingResult {
let processed: Bool
let didEstablishAuthenticatedSession: Bool
}
/// Narrow environment for `BLENoisePacketHandler`.
///
/// All queue hops (collections barrier writes, main-actor UI notification)
@@ -16,8 +21,11 @@ struct BLENoisePacketHandlerEnvironment {
let messageTTL: UInt8
/// Current time source.
let now: () -> Date
/// Processes an inbound handshake message, returning an optional response payload (crypto).
let processHandshakeMessage: (_ peerID: PeerID, _ message: Data) throws -> Data?
/// Processes an inbound handshake message, returning its optional response
/// and whether that exact candidate authenticated (crypto).
let processHandshakeMessage:
(_ peerID: PeerID, _ message: Data) throws
-> NoiseHandshakeProcessingResult
/// Whether any Noise session (established or pending) exists for the peer (crypto).
let hasNoiseSession: (PeerID) -> Bool
/// Initiates a fresh Noise handshake with the peer (crypto + send).
@@ -49,13 +57,28 @@ final class BLENoisePacketHandler {
self.environment = environment
}
func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
/// Returns true when the handshake message was processed successfully.
/// Callers use this to distinguish an authenticated replacement completion
/// from a rejected candidate while an older session remains established.
@discardableResult
func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
handleHandshakeWithResult(packet, from: peerID).processed
}
func handleHandshakeWithResult(
_ packet: BitchatPacket,
from peerID: PeerID
) -> BLENoiseHandshakeHandlingResult {
let env = environment
// Use NoiseEncryptionService for handshake processing
if PeerID(hexData: packet.recipientID) == env.localPeerID() {
// Handshake is for us
do {
if let response = try env.processHandshakeMessage(peerID, packet.payload) {
let result = try env.processHandshakeMessage(
peerID,
packet.payload
)
if let response = result.response {
// Send response
let responsePacket = BitchatPacket(
type: MessageType.noiseHandshake.rawValue,
@@ -72,14 +95,39 @@ final class BLENoisePacketHandler {
// Session establishment will trigger onPeerAuthenticated callback
// which will send any pending messages at the right time
return BLENoiseHandshakeHandlingResult(
processed: true,
didEstablishAuthenticatedSession:
result.didEstablishAuthenticatedSession
)
} catch NoiseSessionError.peerIdentityMismatch {
// The candidate was already discarded by the session manager.
// Do not let a spoofed claimed ID trigger a fresh outbound
// handshake or recreate state for the attacker-selected ID.
SecureLogger.warning(
"Rejected Noise handshake whose static key does not match \(peerID.id.prefix(8))",
category: .security
)
return BLENoiseHandshakeHandlingResult(
processed: false,
didEstablishAuthenticatedSession: false
)
} catch {
SecureLogger.error("Failed to process handshake: \(error)")
// Try initiating a new handshake
if !env.hasNoiseSession(peerID) {
env.initiateHandshake(peerID)
}
return BLENoiseHandshakeHandlingResult(
processed: false,
didEstablishAuthenticatedSession: false
)
}
}
return BLENoiseHandshakeHandlingResult(
processed: false,
didEstablishAuthenticatedSession: false
)
}
func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
+63 -6
View File
@@ -1792,7 +1792,44 @@ final class BLEService: NSObject {
}
}
private func handleLeave(_: BitchatPacket, from peerID: PeerID) {
/// Accept a leave only when the claimed sender proves possession of the
/// signing key bound by a verified announce. The persisted identity cache
/// keeps delayed/relayed leaves verifiable after the live registry entry
/// has aged out.
private func handleLeave(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
let registrySigningKey = collectionsQueue.sync {
peerRegistry.info(for: peerID)?.signingPublicKey
}
let verifiedViaRegistry = registrySigningKey.map {
noiseService.verifyPacketSignature(packet, publicKey: $0)
} ?? false
let verifiedViaPersistedIdentity = !verifiedViaRegistry
&& identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID).contains { identity in
PeerID(publicKey: identity.publicKey) == peerID
&& identity.signingPublicKey.map {
noiseService.verifyPacketSignature(packet, publicKey: $0)
} == true
}
guard verifiedViaRegistry || verifiedViaPersistedIdentity else {
SecureLogger.warning(
"🚫 Dropping leave with missing/invalid signature for claimed sender \(peerID.id.prefix(8))",
category: .security
)
return false
}
// A valid departure retires transport state too; otherwise
// canDeliverSecurely could remain true for a peer we just removed.
noiseService.clearSession(for: peerID)
readLinkState { _ in
let departedLinks = noiseAuthenticatedLinkOwners.compactMap { link, owner in
owner == peerID ? link : nil
}
for link in departedLinks {
noiseAuthenticatedLinkOwners.removeValue(forKey: link)
}
}
_ = collectionsQueue.sync(flags: .barrier) {
// Remove the peer when they leave
peerRegistry.remove(peerID)
@@ -1809,6 +1846,7 @@ final class BLEService: NSObject {
self.deliverTransportEvent(.peerDisconnected(peerID))
self.deliverTransportEvent(.peerListUpdated(currentPeerIDs))
}
return true
}
private func sendAnnounce(forceSend: Bool = false) {
guard !isPanicSuspended else { return }
@@ -2553,6 +2591,12 @@ extension BLEService {
}
}
func _test_isNoiseAuthenticatedCentral(_ centralUUID: String, for peerID: PeerID) -> Bool {
bleQueue.sync {
noiseAuthenticatedLinkOwners[.central(centralUUID)] == peerID
}
}
func _test_seedConnectedPeer(_ peerID: PeerID, nickname: String) {
collectionsQueue.sync(flags: .barrier) {
peerRegistry.upsert(BLEPeerInfo(
@@ -5084,7 +5128,9 @@ extension BLEService {
handleMeshPong(packet, from: senderID)
case .leave:
handleLeave(packet, from: senderID)
// A forged leave must neither evict the claimed peer nor spread
// to downstream nodes.
guard handleLeave(packet, from: senderID) else { return }
case .none:
SecureLogger.warning("⚠️ Unknown message type: \(packet.type)", category: .session)
@@ -5724,9 +5770,11 @@ extension BLEService {
}
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
let wasEstablished = noiseService.hasEstablishedSession(with: peerID)
noisePacketHandler.handleHandshake(packet, from: peerID)
if !wasEstablished, noiseService.hasEstablishedSession(with: peerID) {
let result = noisePacketHandler.handleHandshakeWithResult(
packet,
from: peerID
)
if result.didEstablishAuthenticatedSession {
markNoiseAuthenticatedIngressLink(for: packet, peerID: peerID)
}
}
@@ -5749,7 +5797,16 @@ extension BLEService {
messageTTL: messageTTL,
now: { Date() },
processHandshakeMessage: { [weak self] peerID, message in
try self?.noiseService.processHandshakeMessage(from: peerID, message: message)
guard let self else {
return NoiseHandshakeProcessingResult(
response: nil,
didEstablishAuthenticatedSession: false
)
}
return try self.noiseService.processHandshakeMessageWithResult(
from: peerID,
message: message
)
},
hasNoiseSession: { [weak self] peerID in
self?.noiseService.hasSession(with: peerID) ?? false
+17 -2
View File
@@ -664,6 +664,18 @@ final class NoiseEncryptionService {
/// Process an incoming handshake message
func processHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? {
try processHandshakeMessageWithResult(
from: peerID,
message: message
).response
}
/// Process an incoming handshake message and report whether the exact
/// session that consumed it completed authenticated establishment.
func processHandshakeMessageWithResult(
from peerID: PeerID,
message: Data
) throws -> NoiseHandshakeProcessingResult {
// Validate peer ID
guard peerID.isValid else {
@@ -685,11 +697,14 @@ final class NoiseEncryptionService {
// For handshakes, we process the raw data directly without NoiseMessage wrapper
// The Noise protocol handles its own message format
let responsePayload = try sessionManager.handleIncomingHandshake(from: peerID, message: message)
let result = try sessionManager.handleIncomingHandshakeWithResult(
from: peerID,
message: message
)
// Return raw response without wrapper
return responsePayload
return result
}
/// Check if we have an established session with a peer
+189
View File
@@ -99,6 +99,95 @@ struct BLEServiceCoreTests {
#expect(ble.currentPeerSnapshots().isEmpty)
}
@Test
func unsignedAndBadSignatureLeaveDoNotEvictOrRelayClaimedPeer() async throws {
let ble = makeService()
let alice = NoiseEncryptionService(keychain: MockKeychain())
let mallory = NoiseEncryptionService(keychain: MockKeychain())
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
let outbound = OutboundPacketTap()
ble._test_onOutboundPacket = outbound.record
let unsigned = makeLeavePacket(sender: alicePeerID, marker: "unsigned")
ble._test_handlePacket(
unsigned,
fromPeerID: alicePeerID,
signingPublicKey: alice.getSigningPublicKeyData()
)
let unsignedRelayed = await TestHelpers.waitUntil(
{ outbound.count(ofType: .leave) > 0 },
timeout: TestConstants.shortTimeout
)
#expect(!unsignedRelayed)
#expect(ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID })
let badSignature = try #require(
mallory.signPacket(makeLeavePacket(sender: alicePeerID, marker: "bad-signature"))
)
ble._test_handlePacket(
badSignature,
fromPeerID: alicePeerID,
signingPublicKey: alice.getSigningPublicKeyData()
)
let badSignatureRelayed = await TestHelpers.waitUntil(
{ outbound.count(ofType: .leave) > 0 },
timeout: TestConstants.shortTimeout
)
#expect(!badSignatureRelayed)
#expect(ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID })
}
@Test
func validSignedLeaveEvictsSessionAndRelays() async throws {
let ble = makeService()
let alice = NoiseEncryptionService(keychain: MockKeychain())
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
// Establish a real session so the leave regression also verifies that
// stale secure-delivery state is retired, not just the peer-list row.
let message1 = try ble._test_noiseInitiateHandshake(with: alicePeerID)
let message2 = try #require(
try alice.processHandshakeMessage(from: ble.myPeerID, message: message1)
)
let message3 = try #require(
try ble._test_noiseProcessHandshakeMessage(from: alicePeerID, message: message2)
)
_ = try alice.processHandshakeMessage(from: ble.myPeerID, message: message3)
#expect(ble.canDeliverSecurely(to: alicePeerID))
let centralUUID = "central-valid-leave"
ble._test_bindCentral(centralUUID, to: alicePeerID)
ble._test_markNoiseAuthenticatedCentral(centralUUID, to: alicePeerID)
#expect(ble._test_isNoiseAuthenticatedCentral(centralUUID, for: alicePeerID))
let outbound = OutboundPacketTap()
ble._test_onOutboundPacket = outbound.record
let signedLeave = try #require(
alice.signPacket(makeLeavePacket(sender: alicePeerID, marker: "valid"))
)
ble._test_handlePacket(
signedLeave,
fromPeerID: alicePeerID,
signingPublicKey: alice.getSigningPublicKeyData()
)
let evicted = await TestHelpers.waitUntil(
{
!ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID }
&& !ble.canDeliverSecurely(to: alicePeerID)
&& !ble._test_isNoiseAuthenticatedCentral(centralUUID, for: alicePeerID)
},
timeout: TestConstants.longTimeout
)
#expect(evicted)
let relayed = await TestHelpers.waitUntil(
{ outbound.count(ofType: .leave) == 1 },
timeout: TestConstants.longTimeout
)
#expect(relayed)
}
@Test
func ingressAllowsRelayedSenderOnBoundLink() async throws {
let ble = makeService()
@@ -440,6 +529,94 @@ struct BLEServiceCoreTests {
#expect(outbound.count(ofType: .courierEnvelope) == 0)
}
@Test
func replacementXXMessageOneWithPayloadCannotAuthenticateIngressLink() async throws {
let ble = makeService()
let victim = NoiseEncryptionService(keychain: MockKeychain())
let victimPeerID = PeerID(publicKey: victim.getStaticPublicKeyData())
// Preserve a working victim session while an unauthenticated
// replacement candidate arrives on a newly bound physical link.
let message1 = try ble._test_noiseInitiateHandshake(with: victimPeerID)
let message2 = try #require(
try victim.processHandshakeMessage(from: ble.myPeerID, message: message1)
)
let message3 = try #require(
try ble._test_noiseProcessHandshakeMessage(
from: victimPeerID,
message: message2
)
)
_ = try victim.processHandshakeMessage(
from: ble.myPeerID,
message: message3
)
#expect(ble.canDeliverSecurely(to: victimPeerID))
let centralUUID = "central-replacement-xx-message-one"
ble._test_bindCentral(centralUUID, to: victimPeerID)
#expect(
!ble._test_isNoiseAuthenticatedCentral(
centralUUID,
for: victimPeerID
)
)
// XX message one may legally carry a payload, so its length is not a
// reliable signal that the replacement handshake completed.
let unauthenticatedInitiator = NoiseHandshakeState(
role: .initiator,
pattern: .XX,
keychain: MockKeychain()
)
let replacementMessage1 = try unauthenticatedInitiator.writeMessage(
payload: Data([0xA5])
)
#expect(
replacementMessage1.count
> NoiseSecurityConstants.xxInitialMessageSize
)
let packet = BitchatPacket(
type: MessageType.noiseHandshake.rawValue,
senderID: Data(hexString: victimPeerID.id) ?? Data(),
recipientID: Data(hexString: ble.myPeerID.id),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: replacementMessage1,
signature: nil,
ttl: TransportConfig.messageTTLDefault
)
#expect(
ble._test_recordIngressIfNew(
packet: packet,
linkID: centralUUID
)
)
let outbound = OutboundPacketTap()
ble._test_onOutboundPacket = outbound.record
ble._test_handlePacket(
packet,
fromPeerID: victimPeerID,
preseedPeer: false
)
// Waiting for the responder's message two proves the candidate was
// processed before checking its exact authentication result.
let candidateProcessed = await TestHelpers.waitUntil(
{ outbound.count(ofType: .noiseHandshake) == 1 },
timeout: TestConstants.longTimeout
)
#expect(candidateProcessed)
#expect(
!ble._test_isNoiseAuthenticatedCentral(
centralUUID,
for: victimPeerID
)
)
#expect(ble.canDeliverSecurely(to: victimPeerID))
}
/// A legitimate rotation announce necessarily arrives on a link still
/// bound to the OLD ID, so its registry upsert stores the new peer
/// disconnected. The successful rebind must promote it: a healed
@@ -854,6 +1031,18 @@ private func makePublicPacket(content: String, sender: PeerID, timestamp: UInt64
)
}
private func makeLeavePacket(sender: PeerID, marker: String) -> BitchatPacket {
BitchatPacket(
type: MessageType.leave.rawValue,
senderID: Data(hexString: sender.id) ?? Data(),
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: Data(marker.utf8),
signature: nil,
ttl: TransportConfig.messageTTLDefault
)
}
private final class PublicCaptureDelegate: BitchatDelegate {
private let lock = NSLock()
private(set) var publicMessages: [BitchatMessage] = []
+9 -2
View File
@@ -12,8 +12,15 @@ struct NoiseCoverageTests {
private let bobStaticKey = Curve25519.KeyAgreement.PrivateKey()
private let charlieStaticKey = Curve25519.KeyAgreement.PrivateKey()
private let alicePeerID = PeerID(str: "0011223344556677")
private let bobPeerID = PeerID(str: "8899aabbccddeeff")
// Manager test dictionaries are keyed by the remote peer. Keep the
// historical names, but derive each wire ID from the static key that the
// corresponding manager authenticates during the handshake.
private var alicePeerID: PeerID {
PeerID(publicKey: bobStaticKey.publicKey.rawRepresentation)
}
private var bobPeerID: PeerID {
PeerID(publicKey: aliceStaticKey.publicKey.rawRepresentation)
}
private let charliePeerID = PeerID(str: "fedcba9876543210")
@Test("Protocol metadata and handshake patterns expose expected values")
@@ -8,6 +8,7 @@ struct BLENoisePacketHandlerTests {
private final class Recorder {
var handshakeResult: Result<Data?, Error> = .success(nil)
var handshakeAuthenticated = false
var hasSession = false
var decryptResult: Result<Data, Error> = .success(Data())
@@ -38,7 +39,11 @@ struct BLENoisePacketHandlerTests {
now: { now },
processHandshakeMessage: { peerID, message in
recorder.processedHandshakes.append((peerID, message))
return try recorder.handshakeResult.get()
return NoiseHandshakeProcessingResult(
response: try recorder.handshakeResult.get(),
didEstablishAuthenticatedSession:
recorder.handshakeAuthenticated
)
},
hasNoiseSession: { peerID in
recorder.hasSessionQueries.append(peerID)
@@ -110,6 +115,24 @@ struct BLENoisePacketHandlerTests {
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func handshakeResultPreservesExactCandidateAuthentication() {
let recorder = Recorder()
recorder.handshakeAuthenticated = true
let handler = makeHandler(recorder: recorder)
let packet = makeHandshakePacket(
recipientID: Data(hexString: localPeerID.id)
)
let result = handler.handleHandshakeWithResult(
packet,
from: remotePeerID
)
#expect(result.processed)
#expect(result.didEstablishAuthenticatedSession)
}
@Test
func handshakeForAnotherPeerIsIgnored() {
let recorder = Recorder()
@@ -152,6 +175,21 @@ struct BLENoisePacketHandlerTests {
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func peerIdentityMismatchDoesNotRecreateHandshakeState() {
let recorder = Recorder()
recorder.handshakeResult = .failure(NoiseSessionError.peerIdentityMismatch)
recorder.hasSession = false
let handler = makeHandler(recorder: recorder)
let packet = makeHandshakePacket(recipientID: Data(hexString: localPeerID.id))
#expect(!handler.handleHandshake(packet, from: remotePeerID))
#expect(recorder.hasSessionQueries.isEmpty)
#expect(recorder.initiatedHandshakes.isEmpty)
#expect(recorder.broadcastPackets.isEmpty)
}
// MARK: Encrypted
@Test
@@ -91,39 +91,150 @@ struct NoiseEncryptionServiceTests {
func handshakeEncryptionAndFingerprintLifecycle() async throws {
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let alicePeerID = PeerID(str: "0011223344556677")
let bobPeerID = PeerID(str: "8899aabbccddeeff")
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
let recorder = AuthenticationRecorder()
#expect(alice.onPeerAuthenticated == nil)
alice.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:))
bob.onPeerAuthenticated = recorder.record(peerID:fingerprint:)
try establishSessions(alice: alice, bob: bob, alicePeerID: alicePeerID, bobPeerID: bobPeerID)
try establishSessions(alice: alice, bob: bob)
let authenticated = await TestHelpers.waitUntil({ recorder.count >= 2 }, timeout: 5.0)
#expect(authenticated)
#expect(alice.hasEstablishedSession(with: alicePeerID))
#expect(bob.hasEstablishedSession(with: bobPeerID))
#expect(alice.hasSession(with: alicePeerID))
#expect(bob.hasSession(with: bobPeerID))
#expect(alice.getPeerPublicKeyData(alicePeerID)?.count == 32)
#expect(bob.getPeerPublicKeyData(bobPeerID)?.count == 32)
#expect(alice.getPeerFingerprint(alicePeerID) != nil)
#expect(bob.getPeerFingerprint(bobPeerID) != nil)
#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)
let plaintext = Data("secret payload".utf8)
let ciphertext = try alice.encrypt(plaintext, for: alicePeerID)
let decrypted = try bob.decrypt(ciphertext, from: bobPeerID)
let ciphertext = try alice.encrypt(plaintext, for: bobPeerID)
let decrypted = try bob.decrypt(ciphertext, from: alicePeerID)
#expect(decrypted == plaintext)
alice.clearSession(for: alicePeerID)
#expect(!alice.hasSession(with: alicePeerID))
#expect(alice.getPeerFingerprint(alicePeerID) == nil)
alice.clearSession(for: bobPeerID)
#expect(!alice.hasSession(with: bobPeerID))
#expect(alice.getPeerFingerprint(bobPeerID) == nil)
bob.clearEphemeralStateForPanic()
#expect(!bob.hasSession(with: bobPeerID))
#expect(bob.getPeerFingerprint(bobPeerID) == nil)
#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 replacement preserves the established peer session")
func forgedReplacementPreservesEstablishedSession() 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)
)
// The replacement has not authenticated yet; the working Alice
// transport session must remain available throughout the candidate.
#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 replacement to fail peer binding")
} catch let error as NoiseSessionError {
#expect(error == .peerIdentityMismatch)
} catch {
Issue.record("Unexpected replacement 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 rehandshake atomically replaces the established session")
func validRehandshakeReplacesEstablishedSession() 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("Encrypt without a session requests handshake and decrypt without session fails")
@@ -200,16 +311,16 @@ struct NoiseEncryptionServiceTests {
private func establishSessions(
alice: NoiseEncryptionService,
bob: NoiseEncryptionService,
alicePeerID: PeerID,
bobPeerID: PeerID
bob: NoiseEncryptionService
) throws {
let message1 = try alice.initiateHandshake(with: alicePeerID)
let response = try bob.processHandshakeMessage(from: bobPeerID, message: message1)
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: alicePeerID, message: message2)
let final = try alice.processHandshakeMessage(from: bobPeerID, message: message2)
let message3 = try #require(final, "Expected handshake final")
let finalMessage = try bob.processHandshakeMessage(from: bobPeerID, message: message3)
let finalMessage = try bob.processHandshakeMessage(from: alicePeerID, message: message3)
#expect(finalMessage == nil)
}
}