mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
Bind Noise sessions to claimed peer identities
This commit is contained in:
@@ -15,6 +15,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
|
||||
|
||||
|
||||
@@ -11,4 +11,5 @@ enum NoiseSessionError: Error, Equatable {
|
||||
case notEstablished
|
||||
case sessionNotFound
|
||||
case alreadyEstablished
|
||||
case peerIdentityMismatch
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@ import BitFoundation
|
||||
|
||||
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 +59,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 +70,11 @@ final class NoiseSessionManager {
|
||||
for (_, session) in sessions {
|
||||
session.reset()
|
||||
}
|
||||
for (_, candidate) in responderCandidates {
|
||||
candidate.reset()
|
||||
}
|
||||
sessions.removeAll()
|
||||
responderCandidates.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +91,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 +104,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
|
||||
}
|
||||
@@ -100,39 +114,50 @@ final class NoiseSessionManager {
|
||||
func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
|
||||
// Process everything within the synchronized block to prevent race conditions
|
||||
return try managerQueue.sync(flags: .barrier) {
|
||||
var shouldCreateNew = false
|
||||
var existingSession: NoiseSession? = nil
|
||||
let session: NoiseSession
|
||||
let isReplacementCandidate: Bool
|
||||
|
||||
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
|
||||
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
|
||||
@@ -141,18 +166,40 @@ final class NoiseSessionManager {
|
||||
|
||||
// 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)
|
||||
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
|
||||
} 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
|
||||
@@ -165,6 +212,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
|
||||
|
||||
func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data {
|
||||
|
||||
@@ -49,7 +49,11 @@ 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 {
|
||||
let env = environment
|
||||
// Use NoiseEncryptionService for handshake processing
|
||||
if PeerID(hexData: packet.recipientID) == env.localPeerID() {
|
||||
@@ -72,14 +76,26 @@ final class BLENoisePacketHandler {
|
||||
|
||||
// Session establishment will trigger onPeerAuthenticated callback
|
||||
// which will send any pending messages at the right time
|
||||
return true
|
||||
} 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 false
|
||||
} catch {
|
||||
SecureLogger.error("Failed to process handshake: \(error)")
|
||||
// Try initiating a new handshake
|
||||
if !env.hasNoiseSession(peerID) {
|
||||
env.initiateHandshake(peerID)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
|
||||
@@ -1618,7 +1618,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)
|
||||
@@ -1635,6 +1672,7 @@ final class BLEService: NSObject {
|
||||
self.deliverTransportEvent(.peerDisconnected(peerID))
|
||||
self.deliverTransportEvent(.peerListUpdated(currentPeerIDs))
|
||||
}
|
||||
return true
|
||||
}
|
||||
private func sendAnnounce(forceSend: Bool = false) {
|
||||
// Throttle announces to prevent flooding
|
||||
@@ -2336,6 +2374,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(
|
||||
@@ -4785,7 +4829,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)
|
||||
@@ -5426,8 +5472,14 @@ 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 processed = noisePacketHandler.handleHandshake(packet, from: peerID)
|
||||
let isEstablished = noiseService.hasEstablishedSession(with: peerID)
|
||||
// XX message 1 is exactly the unauthenticated 32-byte ephemeral key.
|
||||
// While replacing an existing session, do not authenticate its ingress
|
||||
// link until a later message completes and validates the candidate.
|
||||
let completedAuthenticatedHandshake = !wasEstablished
|
||||
|| packet.payload.count != NoiseSecurityConstants.xxInitialMessageSize
|
||||
if processed, isEstablished, completedAuthenticatedHandshake {
|
||||
markNoiseAuthenticatedIngressLink(for: packet, peerID: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -690,6 +779,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] = []
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -152,6 +152,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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user