mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 23:05:20 +00:00
Fix cached Noise reconnects atomically
This commit is contained in:
@@ -36,6 +36,10 @@ enum NoiseSecurityConstants {
|
||||
|
||||
// Noise XX message 1 contains only the initiator's 32-byte ephemeral key.
|
||||
static let xxInitialMessageSize = 32
|
||||
|
||||
// Bounds the receive-only rollback quarantine created by an unauthenticated
|
||||
// inbound message 1. A lost message 3 must not strand outbound traffic.
|
||||
static let ordinaryResponderHandshakeTimeout: TimeInterval = 20
|
||||
|
||||
// Session timeout - sessions older than this should be renegotiated
|
||||
static let sessionTimeout: TimeInterval = 86400 // 24 hours
|
||||
|
||||
@@ -11,25 +11,50 @@ import CryptoKit
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
|
||||
struct NoiseHandshakeInitiation: Equatable, Sendable {
|
||||
let payload: Data
|
||||
let attemptID: UUID
|
||||
}
|
||||
|
||||
final class NoiseSessionManager {
|
||||
private var sessions: [PeerID: NoiseSession] = [:]
|
||||
/// Opaque identity for each exact entry in `sessions`. The generation is
|
||||
/// created and removed under the same barrier as the session itself, so a
|
||||
/// caller can never authenticate data with one session and lease another.
|
||||
private var sessionGenerations: [PeerID: UUID] = [:]
|
||||
/// 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] = [:]
|
||||
/// One-time handoff tokens prevent a prepared XX message 1 from leaving
|
||||
/// after an inbound collision has already changed this peer's role.
|
||||
private var ordinaryInitiationIDs: [PeerID: UUID] = [:]
|
||||
private struct QuarantinedTransport {
|
||||
let session: NoiseSession
|
||||
let generation: UUID
|
||||
/// Duplicate message 1 packets replace the incomplete responder but
|
||||
/// never extend the original rollback window indefinitely.
|
||||
let rollbackDeadline: DispatchTime
|
||||
}
|
||||
/// An unauthenticated inbound message 1 cannot keep old sending keys live,
|
||||
/// but it also must not permanently destroy a victim session. The old
|
||||
/// transport remains receive-only while the ordinary responder proves the
|
||||
/// claimed static identity, then is discarded on success or restored on
|
||||
/// bounded failure.
|
||||
private var quarantinedTransports: [PeerID: QuarantinedTransport] = [:]
|
||||
private var quarantinedResponderTimeouts: [PeerID: DispatchWorkItem] = [:]
|
||||
private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession
|
||||
private let ordinaryResponderHandshakeTimeout: TimeInterval
|
||||
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
||||
|
||||
// Callbacks
|
||||
var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey, UUID) -> Void)?
|
||||
var onSessionRestored: ((PeerID, UUID) -> Void)?
|
||||
var onSessionFailed: ((PeerID, Error) -> Void)?
|
||||
|
||||
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
||||
init(
|
||||
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
|
||||
keychain: KeychainManagerProtocol,
|
||||
ordinaryResponderHandshakeTimeout: TimeInterval =
|
||||
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
|
||||
) {
|
||||
self.ordinaryResponderHandshakeTimeout = ordinaryResponderHandshakeTimeout
|
||||
self.sessionFactory = { peerID, role in
|
||||
SecureNoiseSession(
|
||||
peerID: peerID,
|
||||
@@ -44,8 +69,11 @@ final class NoiseSessionManager {
|
||||
init(
|
||||
localStaticKey _: Curve25519.KeyAgreement.PrivateKey,
|
||||
keychain _: KeychainManagerProtocol,
|
||||
ordinaryResponderHandshakeTimeout: TimeInterval =
|
||||
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout,
|
||||
sessionFactory: @escaping (PeerID, NoiseRole) -> NoiseSession
|
||||
) {
|
||||
self.ordinaryResponderHandshakeTimeout = ordinaryResponderHandshakeTimeout
|
||||
self.sessionFactory = sessionFactory
|
||||
}
|
||||
#endif
|
||||
@@ -60,27 +88,38 @@ final class NoiseSessionManager {
|
||||
|
||||
func removeSession(for peerID: PeerID) {
|
||||
managerQueue.sync(flags: .barrier) {
|
||||
if let session = sessions.removeValue(forKey: peerID) {
|
||||
session.reset() // Clear sensitive data before removing
|
||||
}
|
||||
sessionGenerations.removeValue(forKey: peerID)
|
||||
if let candidate = responderCandidates.removeValue(forKey: peerID) {
|
||||
candidate.reset()
|
||||
}
|
||||
removeSessionLocked(for: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
private func removeSessionLocked(for peerID: PeerID) {
|
||||
if let session = sessions.removeValue(forKey: peerID) {
|
||||
session.reset() // Clear sensitive data before removing
|
||||
}
|
||||
if let quarantined = quarantinedTransports.removeValue(forKey: peerID) {
|
||||
quarantined.session.reset()
|
||||
}
|
||||
quarantinedResponderTimeouts.removeValue(forKey: peerID)?.cancel()
|
||||
sessionGenerations.removeValue(forKey: peerID)
|
||||
ordinaryInitiationIDs.removeValue(forKey: peerID)
|
||||
}
|
||||
|
||||
func removeAllSessions() {
|
||||
managerQueue.sync(flags: .barrier) {
|
||||
for (_, session) in sessions {
|
||||
session.reset()
|
||||
}
|
||||
for (_, candidate) in responderCandidates {
|
||||
candidate.reset()
|
||||
for (_, quarantined) in quarantinedTransports {
|
||||
quarantined.session.reset()
|
||||
}
|
||||
for (_, timeout) in quarantinedResponderTimeouts {
|
||||
timeout.cancel()
|
||||
}
|
||||
sessions.removeAll()
|
||||
sessionGenerations.removeAll()
|
||||
responderCandidates.removeAll()
|
||||
ordinaryInitiationIDs.removeAll()
|
||||
quarantinedTransports.removeAll()
|
||||
quarantinedResponderTimeouts.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +137,7 @@ final class NoiseSessionManager {
|
||||
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
sessionGenerations.removeValue(forKey: peerID)
|
||||
ordinaryInitiationIDs.removeValue(forKey: peerID)
|
||||
existingSession.reset()
|
||||
}
|
||||
|
||||
@@ -113,12 +153,73 @@ final class NoiseSessionManager {
|
||||
// Clean up failed session
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
sessionGenerations.removeValue(forKey: peerID)
|
||||
ordinaryInitiationIDs.removeValue(forKey: peerID)
|
||||
session.reset()
|
||||
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Prepares an ordinary reconnect before atomically retiring the current
|
||||
/// transport. Authorization and handshake-start failure leave the working
|
||||
/// session untouched. Once this returns, encryption observes only the new
|
||||
/// handshaking session and must queue until it establishes.
|
||||
func initiateReconnectHandshake(
|
||||
with peerID: PeerID,
|
||||
authorize: () throws -> Void
|
||||
) throws -> NoiseHandshakeInitiation {
|
||||
try managerQueue.sync(flags: .barrier) {
|
||||
guard let established = sessions[peerID],
|
||||
established.isEstablished() else {
|
||||
throw NoiseSessionError.notEstablished
|
||||
}
|
||||
try authorize()
|
||||
|
||||
let next = sessionFactory(peerID, .initiator)
|
||||
let payload: Data
|
||||
do {
|
||||
payload = try next.startHandshake()
|
||||
} catch {
|
||||
next.reset()
|
||||
SecureLogger.error(
|
||||
.handshakeFailed(
|
||||
peerID: peerID.id,
|
||||
error: error.localizedDescription
|
||||
)
|
||||
)
|
||||
throw error
|
||||
}
|
||||
|
||||
removeSessionLocked(for: peerID)
|
||||
let attemptID = UUID()
|
||||
sessions[peerID] = next
|
||||
sessionGenerations[peerID] = UUID()
|
||||
ordinaryInitiationIDs[peerID] = attemptID
|
||||
return NoiseHandshakeInitiation(
|
||||
payload: payload,
|
||||
attemptID: attemptID
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Claims a prepared message 1 exactly once. A crossed inbound initiation
|
||||
/// that already made this peer a responder invalidates the token.
|
||||
func claimHandshakeInitiation(
|
||||
_ initiation: NoiseHandshakeInitiation,
|
||||
for peerID: PeerID
|
||||
) -> Data? {
|
||||
managerQueue.sync(flags: .barrier) {
|
||||
guard ordinaryInitiationIDs[peerID] == initiation.attemptID,
|
||||
let session = sessions[peerID],
|
||||
session.role == .initiator,
|
||||
session.getState() == .handshaking else {
|
||||
return nil
|
||||
}
|
||||
ordinaryInitiationIDs.removeValue(forKey: peerID)
|
||||
return initiation.payload
|
||||
}
|
||||
}
|
||||
|
||||
func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
|
||||
// Process everything within the synchronized block to prevent race conditions.
|
||||
@@ -133,52 +234,52 @@ final class NoiseSessionManager {
|
||||
)?
|
||||
) = try managerQueue.sync(flags: .barrier) {
|
||||
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 {
|
||||
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.
|
||||
if let existing = sessions[peerID],
|
||||
message.count == NoiseSecurityConstants.xxInitialMessageSize {
|
||||
if existing.isEstablished(),
|
||||
let generation = sessionGenerations[peerID] {
|
||||
// Message 1 cannot prove that the remote still owns its
|
||||
// old keys. Remove them from every sending/generation API
|
||||
// immediately, but retain the object solely for bounded
|
||||
// rollback until the ordinary responder authenticates.
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
sessionGenerations.removeValue(forKey: peerID)
|
||||
existing.reset()
|
||||
let replacement = sessionFactory(peerID, .responder)
|
||||
sessions[peerID] = replacement
|
||||
sessionGenerations[peerID] = UUID()
|
||||
session = replacement
|
||||
isReplacementCandidate = false
|
||||
ordinaryInitiationIDs.removeValue(forKey: peerID)
|
||||
if let prior = quarantinedTransports.updateValue(
|
||||
QuarantinedTransport(
|
||||
session: existing,
|
||||
generation: generation,
|
||||
rollbackDeadline: .now()
|
||||
+ ordinaryResponderHandshakeTimeout
|
||||
),
|
||||
forKey: peerID
|
||||
) {
|
||||
prior.session.reset()
|
||||
}
|
||||
} else {
|
||||
session = existing
|
||||
isReplacementCandidate = false
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
sessionGenerations.removeValue(forKey: peerID)
|
||||
ordinaryInitiationIDs.removeValue(forKey: peerID)
|
||||
existing.reset()
|
||||
}
|
||||
let replacement = sessionFactory(peerID, .responder)
|
||||
sessions[peerID] = replacement
|
||||
sessionGenerations[peerID] = UUID()
|
||||
session = replacement
|
||||
if quarantinedTransports[peerID] != nil {
|
||||
scheduleQuarantinedResponderTimeoutLocked(
|
||||
replacement,
|
||||
for: peerID
|
||||
)
|
||||
}
|
||||
} else if let existing = sessions[peerID] {
|
||||
session = existing
|
||||
} else {
|
||||
let newSession = sessionFactory(peerID, .responder)
|
||||
sessions[peerID] = newSession
|
||||
sessionGenerations[peerID] = UUID()
|
||||
session = newSession
|
||||
isReplacementCandidate = false
|
||||
}
|
||||
|
||||
// Process the handshake message within the synchronized block
|
||||
@@ -196,14 +297,15 @@ final class NoiseSessionManager {
|
||||
throw NoiseSessionError.peerIdentityMismatch
|
||||
}
|
||||
|
||||
if isReplacementCandidate {
|
||||
_ = responderCandidates.removeValue(forKey: peerID)
|
||||
let previous = sessions.updateValue(session, forKey: peerID)
|
||||
sessionGenerations[peerID] = UUID()
|
||||
if let previous, previous !== session {
|
||||
previous.reset()
|
||||
}
|
||||
if let quarantined = quarantinedTransports.removeValue(
|
||||
forKey: peerID
|
||||
) {
|
||||
quarantinedResponderTimeouts.removeValue(
|
||||
forKey: peerID
|
||||
)?.cancel()
|
||||
quarantined.session.reset()
|
||||
}
|
||||
ordinaryInitiationIDs.removeValue(forKey: peerID)
|
||||
guard let generation = sessionGenerations[peerID] else {
|
||||
throw NoiseEncryptionError.sessionNotEstablished
|
||||
}
|
||||
@@ -212,23 +314,28 @@ final class NoiseSessionManager {
|
||||
|
||||
return (response, establishedSession)
|
||||
} catch {
|
||||
// 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 {
|
||||
if let storedSession = sessions[peerID],
|
||||
storedSession === session {
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
sessionGenerations.removeValue(forKey: peerID)
|
||||
}
|
||||
ordinaryInitiationIDs.removeValue(forKey: peerID)
|
||||
session.reset()
|
||||
quarantinedResponderTimeouts.removeValue(forKey: peerID)?.cancel()
|
||||
let restoredGeneration: UUID?
|
||||
if let quarantined = quarantinedTransports.removeValue(forKey: peerID) {
|
||||
sessions[peerID] = quarantined.session
|
||||
sessionGenerations[peerID] = quarantined.generation
|
||||
restoredGeneration = quarantined.generation
|
||||
} else {
|
||||
restoredGeneration = nil
|
||||
}
|
||||
|
||||
// Schedule callback outside the synchronized block to prevent deadlock
|
||||
DispatchQueue.global().async { [weak self] in
|
||||
if let restoredGeneration {
|
||||
self?.onSessionRestored?(peerID, restoredGeneration)
|
||||
}
|
||||
self?.onSessionFailed?(peerID, error)
|
||||
}
|
||||
|
||||
@@ -243,6 +350,47 @@ final class NoiseSessionManager {
|
||||
return result.response
|
||||
}
|
||||
|
||||
private func scheduleQuarantinedResponderTimeoutLocked(
|
||||
_ responder: NoiseSession,
|
||||
for peerID: PeerID
|
||||
) {
|
||||
quarantinedResponderTimeouts.removeValue(forKey: peerID)?.cancel()
|
||||
let timeout = DispatchWorkItem(flags: .barrier) { [weak self, weak responder] in
|
||||
guard let self,
|
||||
let responder,
|
||||
let current = self.sessions[peerID],
|
||||
current === responder,
|
||||
current.role == .responder,
|
||||
current.getState() == .handshaking,
|
||||
let quarantined = self.quarantinedTransports.removeValue(
|
||||
forKey: peerID
|
||||
) else {
|
||||
return
|
||||
}
|
||||
|
||||
_ = self.sessions.removeValue(forKey: peerID)
|
||||
self.sessionGenerations.removeValue(forKey: peerID)
|
||||
self.ordinaryInitiationIDs.removeValue(forKey: peerID)
|
||||
self.quarantinedResponderTimeouts.removeValue(forKey: peerID)
|
||||
responder.reset()
|
||||
self.sessions[peerID] = quarantined.session
|
||||
self.sessionGenerations[peerID] = quarantined.generation
|
||||
SecureLogger.debug(
|
||||
"Ordinary responder handshake with \(peerID) timed out; restored quarantined transport",
|
||||
category: .session
|
||||
)
|
||||
DispatchQueue.global().async { [weak self] in
|
||||
self?.onSessionRestored?(peerID, quarantined.generation)
|
||||
}
|
||||
}
|
||||
quarantinedResponderTimeouts[peerID] = timeout
|
||||
guard let deadline = quarantinedTransports[peerID]?.rollbackDeadline else {
|
||||
quarantinedResponderTimeouts.removeValue(forKey: peerID)
|
||||
return
|
||||
}
|
||||
managerQueue.asyncAfter(deadline: deadline, execute: timeout)
|
||||
}
|
||||
|
||||
/// 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;
|
||||
@@ -274,7 +422,7 @@ final class NoiseSessionManager {
|
||||
|
||||
/// Encrypts only if `expected` still names the current established entry.
|
||||
/// A rekey between capability proof and media encryption therefore fails
|
||||
/// closed instead of sending on an unproven replacement session.
|
||||
/// closed instead of sending on an unproven reconnect session.
|
||||
func encrypt(
|
||||
_ plaintext: Data,
|
||||
for peerID: PeerID,
|
||||
@@ -309,19 +457,49 @@ final class NoiseSessionManager {
|
||||
from peerID: PeerID
|
||||
) throws -> (plaintext: Data, sessionGeneration: UUID) {
|
||||
try managerQueue.sync {
|
||||
guard let session = sessions[peerID] else {
|
||||
if let session = sessions[peerID],
|
||||
session.isEstablished(),
|
||||
let generation = sessionGenerations[peerID] {
|
||||
return (try session.decrypt(ciphertext), generation)
|
||||
}
|
||||
|
||||
// Quarantine is receive-only: old keys cannot encrypt, advertise
|
||||
// an established generation, or authorize outbound state, but
|
||||
// legitimate in-flight ciphertext from the retained peer may
|
||||
// still advance and later resume on rollback.
|
||||
if let responder = sessions[peerID],
|
||||
responder.role == .responder,
|
||||
responder.getState() == .handshaking,
|
||||
let quarantined = quarantinedTransports[peerID] {
|
||||
return (
|
||||
try quarantined.session.decrypt(ciphertext),
|
||||
quarantined.generation
|
||||
)
|
||||
}
|
||||
|
||||
if sessions[peerID] == nil, quarantinedTransports[peerID] == nil {
|
||||
throw NoiseSessionError.sessionNotFound
|
||||
}
|
||||
guard session.isEstablished(),
|
||||
let generation = sessionGenerations[peerID] else {
|
||||
throw NoiseEncryptionError.sessionNotEstablished
|
||||
throw NoiseEncryptionError.sessionNotEstablished
|
||||
}
|
||||
}
|
||||
|
||||
func hasReceiveSession(for peerID: PeerID) -> Bool {
|
||||
managerQueue.sync {
|
||||
if sessions[peerID]?.isEstablished() == true {
|
||||
return true
|
||||
}
|
||||
return (try session.decrypt(ciphertext), generation)
|
||||
guard let responder = sessions[peerID],
|
||||
responder.role == .responder,
|
||||
responder.getState() == .handshaking else {
|
||||
return false
|
||||
}
|
||||
return quarantinedTransports[peerID]?.session.isEstablished() == true
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a state commit under a read lease for the exact established
|
||||
/// session. Rekey, replacement, and removal all need the same barrier.
|
||||
/// session. Rekey, reconnect, and removal all need the same barrier.
|
||||
func withCurrentSessionGeneration<Result>(
|
||||
for peerID: PeerID,
|
||||
expected: UUID,
|
||||
@@ -359,10 +537,13 @@ final class NoiseSessionManager {
|
||||
}
|
||||
|
||||
func initiateRekey(for peerID: PeerID) throws -> Data {
|
||||
// Remove old session
|
||||
removeSession(for: peerID)
|
||||
|
||||
// Initiate new handshake
|
||||
return try initiateHandshake(with: peerID)
|
||||
let initiation = try initiateReconnectHandshake(
|
||||
with: peerID,
|
||||
authorize: {}
|
||||
)
|
||||
guard let payload = claimHandshakeInitiation(initiation, for: peerID) else {
|
||||
throw NoiseSessionError.invalidState
|
||||
}
|
||||
return payload
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,8 +62,8 @@ final class BLENoisePacketHandler {
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Callers use this to distinguish an authenticated reconnect completion
|
||||
/// from a rejected ordinary responder while rollback state is restored.
|
||||
@discardableResult
|
||||
func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
|
||||
let env = environment
|
||||
@@ -90,7 +90,7 @@ final class BLENoisePacketHandler {
|
||||
// which will send any pending messages at the right time
|
||||
return true
|
||||
} catch NoiseSessionError.peerIdentityMismatch {
|
||||
// The candidate was already discarded by the session manager.
|
||||
// The responder 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(
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
|
||||
/// Bounds ordinary Noise revalidation to one attempt per physical-link epoch.
|
||||
/// A live epoch may retry after the cooldown so a lost handshake cannot leave
|
||||
/// the link permanently unauthenticated.
|
||||
struct BLENoiseReconnectPolicy {
|
||||
static let minimumRetryInterval: TimeInterval = 60
|
||||
|
||||
private var lastAttemptAt: [BLEIngressLinkID: Date] = [:]
|
||||
|
||||
mutating func shouldRevalidate(
|
||||
on link: BLEIngressLinkID,
|
||||
hasEstablishedSession: Bool,
|
||||
isNoiseAuthenticatedLink: Bool,
|
||||
hasAuthenticatedPeerLink: Bool,
|
||||
now: Date
|
||||
) -> Bool {
|
||||
guard hasEstablishedSession,
|
||||
!isNoiseAuthenticatedLink,
|
||||
!hasAuthenticatedPeerLink else {
|
||||
return false
|
||||
}
|
||||
if let previous = lastAttemptAt[link],
|
||||
now.timeIntervalSince(previous) < Self.minimumRetryInterval {
|
||||
return false
|
||||
}
|
||||
lastAttemptAt[link] = now
|
||||
return true
|
||||
}
|
||||
|
||||
/// Link identifiers can be stable across CoreBluetooth reconnects, so a
|
||||
/// disconnect explicitly starts a new epoch and permits one fresh attempt.
|
||||
mutating func endLinkEpoch(_ link: BLEIngressLinkID) {
|
||||
lastAttemptAt.removeValue(forKey: link)
|
||||
}
|
||||
|
||||
mutating func removeAll() {
|
||||
lastAttemptAt.removeAll()
|
||||
}
|
||||
}
|
||||
@@ -241,6 +241,7 @@ final class BLEService: NSObject {
|
||||
// that the session was established *on this current ingress link*, not
|
||||
// merely that some session exists for the claimed ID. bleQueue-owned.
|
||||
private var noiseAuthenticatedLinkOwners: [BLEIngressLinkID: PeerID] = [:]
|
||||
private var noiseReconnectPolicy = BLENoiseReconnectPolicy()
|
||||
|
||||
// Rotation-rebind cooldown per link UUID (bleQueue-owned, like the link
|
||||
// store): entries older than the cooldown are pruned on insert.
|
||||
@@ -311,6 +312,9 @@ final class BLEService: NSObject {
|
||||
/// May block in tests to hold the serial message queue immediately before
|
||||
/// the deferred private-media admission check.
|
||||
var _test_beforePrivateMediaDeferredSend: ((String) -> Void)?
|
||||
/// May block announce handling after verified-link rebind work is queued.
|
||||
/// Tests use this boundary to prove rebind and reconnect are serialized.
|
||||
var _test_afterVerifiedDirectRebindEnqueued: (() -> Void)?
|
||||
#endif
|
||||
private var selfBroadcastTracker = BLESelfBroadcastTracker()
|
||||
private let meshTopology = MeshTopologyTracker()
|
||||
@@ -764,6 +768,8 @@ final class BLEService: NSObject {
|
||||
|
||||
bleQueue.sync {
|
||||
pendingWriteBuffers.removeAll()
|
||||
noiseAuthenticatedLinkOwners.removeAll()
|
||||
noiseReconnectPolicy.removeAll()
|
||||
connectionScheduler.reset()
|
||||
}
|
||||
disconnectNotifyDebouncer.removeAll()
|
||||
@@ -1061,6 +1067,7 @@ final class BLEService: NSObject {
|
||||
bleQueue.sync {
|
||||
linkStateStore.clearAll()
|
||||
noiseAuthenticatedLinkOwners.removeAll()
|
||||
noiseReconnectPolicy.removeAll()
|
||||
connectionScheduler.reset()
|
||||
subscriptionAnnounceLimiter.removeAll()
|
||||
}
|
||||
@@ -2617,6 +2624,7 @@ final class BLEService: NSObject {
|
||||
}
|
||||
for link in departedLinks {
|
||||
noiseAuthenticatedLinkOwners.removeValue(forKey: link)
|
||||
noiseReconnectPolicy.endLinkEpoch(link)
|
||||
}
|
||||
}
|
||||
_ = collectionsQueue.sync(flags: .barrier) {
|
||||
@@ -3097,6 +3105,7 @@ extension BLEService: CBCentralManagerDelegate {
|
||||
pendingPeripheralWrites.discardAll(for: peripheralID)
|
||||
}
|
||||
noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
|
||||
noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
|
||||
_ = linkStateStore.removePeripheral(peripheralID)
|
||||
// A duplicate link can drop while the peer stays live on another
|
||||
// (the dual-role central link, or a second bound link after a
|
||||
@@ -3151,6 +3160,7 @@ extension BLEService: CBCentralManagerDelegate {
|
||||
pendingPeripheralWrites.discardAll(for: peripheralID)
|
||||
}
|
||||
noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
|
||||
noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
|
||||
_ = linkStateStore.removePeripheral(peripheralID)
|
||||
|
||||
SecureLogger.error("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: .session)
|
||||
@@ -3258,6 +3268,7 @@ extension BLEService {
|
||||
self.pendingPeripheralWrites.discardAll(for: peripheralID)
|
||||
}
|
||||
self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
|
||||
self.noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
|
||||
_ = self.linkStateStore.removePeripheral(peripheralID)
|
||||
self.connectionScheduler.recordConnectionTimeout(peripheralID: peripheralID, at: Date())
|
||||
self.tryConnectFromQueue()
|
||||
@@ -3966,6 +3977,7 @@ extension BLEService: CBPeripheralManagerDelegate {
|
||||
pendingNotifications.removeTarget { $0.identifier.uuidString == centralID }
|
||||
}
|
||||
noiseAuthenticatedLinkOwners.removeValue(forKey: .central(centralID))
|
||||
noiseReconnectPolicy.endLinkEpoch(.central(centralID))
|
||||
let removedPeerID = linkStateStore.removeSubscribedCentral(central)
|
||||
|
||||
// Ensure we're still advertising for other devices to find us
|
||||
@@ -4583,6 +4595,40 @@ extension BLEService {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A peer-level session can outlive the physical link that established it.
|
||||
/// Revalidate a fresh direct link with an ordinary XX exchange, retiring
|
||||
/// cached sending keys atomically before message 1 can leave.
|
||||
private func refreshNoiseSessionForVerifiedDirectLink(
|
||||
_ packet: BitchatPacket,
|
||||
peerID: PeerID
|
||||
) {
|
||||
guard let link = collectionsQueue.sync(execute: { ingressLinks.link(for: packet) }) else {
|
||||
return
|
||||
}
|
||||
|
||||
let hasEstablishedSession = noiseService.hasEstablishedSession(with: peerID)
|
||||
let authenticatedPeerLinks = currentNoiseAuthenticatedLinks(to: peerID)
|
||||
let shouldRevalidate = readLinkState { store in
|
||||
guard boundPeerID(for: link, in: store) == peerID else {
|
||||
return false
|
||||
}
|
||||
return noiseReconnectPolicy.shouldRevalidate(
|
||||
on: link,
|
||||
hasEstablishedSession: hasEstablishedSession,
|
||||
isNoiseAuthenticatedLink: noiseAuthenticatedLinkOwners[link] == peerID,
|
||||
hasAuthenticatedPeerLink: !authenticatedPeerLinks.isEmpty,
|
||||
now: Date()
|
||||
)
|
||||
}
|
||||
guard shouldRevalidate else { return }
|
||||
|
||||
SecureLogger.info(
|
||||
"🔄 Revalidating cached Noise session on fresh direct link to \(peerID.id.prefix(8))…",
|
||||
category: .session
|
||||
)
|
||||
initiateNoiseReconnectHandshake(with: peerID)
|
||||
}
|
||||
|
||||
private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) {
|
||||
service.onPeerAuthenticatedWithGeneration = { [weak self] peerID, fingerprint, generation in
|
||||
@@ -4602,6 +4648,29 @@ extension BLEService {
|
||||
self.broadcastNoiseHandshake(message, to: peerID)
|
||||
}
|
||||
}
|
||||
service.onSessionRestoredWithGeneration = { [weak self, weak service] peerID, generation in
|
||||
guard let self, let service else { return }
|
||||
self.messageQueue.async { [weak self, weak service] in
|
||||
guard let self,
|
||||
let service,
|
||||
self.noiseService === service,
|
||||
let fingerprint = service.getPeerFingerprint(peerID) else {
|
||||
return
|
||||
}
|
||||
SecureLogger.debug(
|
||||
"🔐 Restored quarantined Noise session with \(peerID.id.prefix(8))…",
|
||||
category: .session
|
||||
)
|
||||
// Re-enter the same generation-bound transition used after a
|
||||
// successful handshake. This restores authenticated protocol
|
||||
// state and drains both PM and typed-payload queues.
|
||||
self.handleNoisePeerAuthenticated(
|
||||
peerID: peerID,
|
||||
fingerprint: fingerprint,
|
||||
sessionGeneration: generation
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleNoisePeerAuthenticated(
|
||||
@@ -4855,8 +4924,9 @@ extension BLEService {
|
||||
}
|
||||
return
|
||||
}
|
||||
guard noiseService.hasSession(with: peerID) else {
|
||||
// No session yet - queue the payload SYNCHRONOUSLY before initiating handshake
|
||||
guard noiseService.hasEstablishedSession(with: peerID) else {
|
||||
// No established session yet - queue the payload synchronously
|
||||
// before initiating a handshake
|
||||
// to prevent race where fast handshake completion drains empty queue
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
self.pendingNoiseSessionQueues.appendTypedPayload(typedPayload, for: peerID)
|
||||
@@ -5786,6 +5856,7 @@ extension BLEService {
|
||||
self.pendingPeripheralWrites.discardAll(for: peripheralID)
|
||||
}
|
||||
self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
|
||||
self.noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
|
||||
_ = self.linkStateStore.removePeripheral(peripheralID)
|
||||
cancelled += 1
|
||||
}
|
||||
@@ -5874,6 +5945,37 @@ extension BLEService {
|
||||
)
|
||||
broadcastPacket(packet)
|
||||
}
|
||||
|
||||
/// Starts a wire-compatible ordinary XX reconnect. The manager prepares
|
||||
/// the initiator before atomically retiring the cached transport; the
|
||||
/// one-shot claim prevents a crossed inbound message from making a stale
|
||||
/// message 1 leave after this peer has already become responder.
|
||||
private func initiateNoiseReconnectHandshake(with peerID: PeerID) {
|
||||
let service = noiseService
|
||||
do {
|
||||
let initiation = try service.initiateReconnectHandshake(with: peerID)
|
||||
noteNoiseSessionCleared(for: peerID)
|
||||
messageQueue.async(flags: .barrier) { [weak self, weak service] in
|
||||
guard let self,
|
||||
let service,
|
||||
self.noiseService === service,
|
||||
let handshakeData = service.claimHandshakeInitiation(
|
||||
initiation,
|
||||
for: peerID
|
||||
) else {
|
||||
return
|
||||
}
|
||||
self.broadcastNoiseHandshake(handshakeData, to: peerID)
|
||||
}
|
||||
} catch NoiseSessionError.notEstablished {
|
||||
initiateNoiseHandshake(with: peerID)
|
||||
} catch {
|
||||
SecureLogger.error(
|
||||
"Failed to initiate ordinary reconnect: \(error)",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func sendPendingMessagesAfterHandshake(for peerID: PeerID) {
|
||||
// Atomically take all pending messages to process (prevents concurrent modification)
|
||||
@@ -6429,6 +6531,9 @@ extension BLEService {
|
||||
// consolidate duplicate same-role connections onto that link.
|
||||
if let result, result.isVerified, result.isDirectAnnounce {
|
||||
rebindLinkAfterVerifiedDirectAnnounce(packet, to: result.peerID)
|
||||
#if DEBUG
|
||||
_test_afterVerifiedDirectRebindEnqueued?()
|
||||
#endif
|
||||
retireRedundantPeripheralLinks(packet, to: result.peerID)
|
||||
}
|
||||
|
||||
@@ -6462,11 +6567,9 @@ extension BLEService {
|
||||
deliverCourierMailRemotely(to: result.peerID, noiseKey: noiseKey)
|
||||
if result.isDirectAnnounce,
|
||||
!hasCurrentNoiseAuthenticatedLink(to: result.peerID) {
|
||||
if noiseService.hasEstablishedSession(with: result.peerID) {
|
||||
// A session with no surviving authenticated link is stale;
|
||||
// force the current link to prove possession again.
|
||||
clearNoiseSession(for: result.peerID)
|
||||
}
|
||||
// A cached session may predate this physical link.
|
||||
// rebindLinkAfterVerifiedDirectAnnounce performs its atomic
|
||||
// ordinary reconnect after the binding is published.
|
||||
if !noiseService.hasSession(with: result.peerID) {
|
||||
initiateNoiseHandshake(with: result.peerID)
|
||||
}
|
||||
@@ -6495,7 +6598,14 @@ extension BLEService {
|
||||
linkUUID = centralUUID
|
||||
previousPeerID = self.linkStateStore.peerID(forCentralUUID: centralUUID)
|
||||
}
|
||||
guard let previousPeerID, previousPeerID != peerID else { return }
|
||||
guard let previousPeerID else { return }
|
||||
guard previousPeerID != peerID else {
|
||||
self.refreshNoiseSessionForVerifiedDirectLink(
|
||||
packet,
|
||||
peerID: peerID
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// The signature does not authenticate directness (TTL is excluded
|
||||
// from signing because relays mutate it), so a "verified direct"
|
||||
@@ -6522,12 +6632,20 @@ extension BLEService {
|
||||
// it across an announce-driven rebind, whose direct TTL is
|
||||
// replayable; the new owner must complete a fresh handshake.
|
||||
self.noiseAuthenticatedLinkOwners.removeValue(forKey: link)
|
||||
self.noiseReconnectPolicy.endLinkEpoch(link)
|
||||
switch link {
|
||||
case .peripheral(let peripheralUUID):
|
||||
self.linkStateStore.bindPeripheral(peripheralUUID, to: peerID)
|
||||
case .central(let centralUUID):
|
||||
self.linkStateStore.bindCentral(centralUUID, to: peerID)
|
||||
}
|
||||
// Keep the rebind and reconnect decision in one bleQueue critical
|
||||
// section. No observer may see the new binding while a cached
|
||||
// peer-level sender is still considered established.
|
||||
self.refreshNoiseSessionForVerifiedDirectLink(
|
||||
packet,
|
||||
peerID: peerID
|
||||
)
|
||||
SecureLogger.debug("🔄 Rebinding link after peer-ID rotation: \(previousPeerID.id.prefix(8))… → \(peerID.id.prefix(8))…", category: .session)
|
||||
self.refreshLocalTopology()
|
||||
// The announce that triggered this rebind was upserted as
|
||||
@@ -6619,6 +6737,7 @@ extension BLEService {
|
||||
pendingPeripheralWrites.discardAll(for: uuid)
|
||||
}
|
||||
noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(uuid))
|
||||
noiseReconnectPolicy.endLinkEpoch(.peripheral(uuid))
|
||||
_ = linkStateStore.removePeripheral(uuid)
|
||||
SecureLogger.info(
|
||||
"🔗 Retiring redundant link \(uuid.prefix(8))… bound to \(peerID.id.prefix(8))…\(keptUUID.map { " (keeping \($0.prefix(8))…)" } ?? "")",
|
||||
@@ -7004,9 +7123,14 @@ extension BLEService {
|
||||
let wasEstablished = noiseService.hasEstablishedSession(with: peerID)
|
||||
let processed = noisePacketHandler.handleHandshake(packet, from: peerID)
|
||||
let isEstablished = noiseService.hasEstablishedSession(with: peerID)
|
||||
if wasEstablished,
|
||||
packet.payload.count == NoiseSecurityConstants.xxInitialMessageSize,
|
||||
!isEstablished {
|
||||
noteNoiseSessionCleared(for: 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.
|
||||
// During an ordinary reconnect, do not authenticate its ingress link
|
||||
// until a later message completes and proves the responder identity.
|
||||
let completedAuthenticatedHandshake = !wasEstablished
|
||||
|| packet.payload.count != NoiseSecurityConstants.xxInitialMessageSize
|
||||
if processed, isEstablished, completedAuthenticatedHandshake {
|
||||
|
||||
@@ -189,6 +189,10 @@ final class NoiseEncryptionService {
|
||||
/// on the wire; merely reporting "handshake required" strands the partial
|
||||
/// initiator session because a second initiate call sees it already exists.
|
||||
var onRekeyHandshakeReady: ((_ peerID: PeerID, _ message: Data) -> Void)?
|
||||
/// An unauthenticated reconnect attempt failed or timed out and the
|
||||
/// receive-only rollback session became the active transport again.
|
||||
/// Transport queues must be drained for this exact restored generation.
|
||||
var onSessionRestoredWithGeneration: ((_ peerID: PeerID, _ generation: UUID) -> Void)?
|
||||
|
||||
// Add a handler for peer authentication
|
||||
func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) {
|
||||
@@ -219,7 +223,11 @@ final class NoiseEncryptionService {
|
||||
}
|
||||
}
|
||||
|
||||
init(keychain: KeychainManagerProtocol) {
|
||||
init(
|
||||
keychain: KeychainManagerProtocol,
|
||||
ordinaryResponderHandshakeTimeout: TimeInterval =
|
||||
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
|
||||
) {
|
||||
self.keychain = keychain
|
||||
self.localPrekeys = LocalPrekeyStore(keychain: keychain)
|
||||
|
||||
@@ -309,7 +317,11 @@ final class NoiseEncryptionService {
|
||||
self.signingPublicKey = signingKey.publicKey
|
||||
|
||||
// Initialize session manager
|
||||
self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain)
|
||||
self.sessionManager = NoiseSessionManager(
|
||||
localStaticKey: staticIdentityKey,
|
||||
keychain: keychain,
|
||||
ordinaryResponderHandshakeTimeout: ordinaryResponderHandshakeTimeout
|
||||
)
|
||||
|
||||
// Set up session callbacks
|
||||
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey, generation in
|
||||
@@ -319,6 +331,9 @@ final class NoiseEncryptionService {
|
||||
sessionGeneration: generation
|
||||
)
|
||||
}
|
||||
sessionManager.onSessionRestored = { [weak self] peerID, generation in
|
||||
self?.onSessionRestoredWithGeneration?(peerID, generation)
|
||||
}
|
||||
|
||||
// Start session maintenance timer
|
||||
startRekeyTimer()
|
||||
@@ -682,6 +697,37 @@ final class NoiseEncryptionService {
|
||||
let handshakeData = try sessionManager.initiateHandshake(with: peerID)
|
||||
return handshakeData
|
||||
}
|
||||
|
||||
/// Atomically prepares an ordinary reconnect for a peer whose cached
|
||||
/// transport belongs to an earlier physical link. Failed authorization or
|
||||
/// handshake setup preserves the established session.
|
||||
func initiateReconnectHandshake(
|
||||
with peerID: PeerID
|
||||
) throws -> NoiseHandshakeInitiation {
|
||||
guard peerID.isValid else {
|
||||
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
|
||||
throw NoiseSecurityError.invalidPeerID
|
||||
}
|
||||
|
||||
return try sessionManager.initiateReconnectHandshake(
|
||||
with: peerID,
|
||||
authorize: { [rateLimiter] in
|
||||
guard rateLimiter.allowHandshake(from: peerID) else {
|
||||
SecureLogger.warning(
|
||||
.authenticationFailed(peerID: "Rate limited: \(peerID)")
|
||||
)
|
||||
throw NoiseSecurityError.rateLimitExceeded
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func claimHandshakeInitiation(
|
||||
_ initiation: NoiseHandshakeInitiation,
|
||||
for peerID: PeerID
|
||||
) -> Data? {
|
||||
sessionManager.claimHandshakeInitiation(initiation, for: peerID)
|
||||
}
|
||||
|
||||
/// Process an incoming handshake message
|
||||
func processHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? {
|
||||
@@ -706,7 +752,10 @@ 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 responsePayload = try sessionManager.handleIncomingHandshake(
|
||||
from: peerID,
|
||||
message: message
|
||||
)
|
||||
|
||||
|
||||
// Return raw response without wrapper
|
||||
@@ -804,8 +853,10 @@ final class NoiseEncryptionService {
|
||||
throw NoiseSecurityError.rateLimitExceeded
|
||||
}
|
||||
|
||||
// Check if we have an established session
|
||||
guard hasEstablishedSession(with: peerID) else {
|
||||
// A quarantined transport is deliberately unavailable for outbound
|
||||
// state, but remains receive-only until the responder proves identity
|
||||
// or the bounded rollback restores it.
|
||||
guard sessionManager.hasReceiveSession(for: peerID) else {
|
||||
throw NoiseEncryptionError.sessionNotEstablished
|
||||
}
|
||||
|
||||
|
||||
@@ -502,14 +502,26 @@ struct BLEServiceCoreTests {
|
||||
)
|
||||
let replay = try #require(victim.signPacket(unsigned), "Failed to sign replayed announce")
|
||||
#expect(ble._test_recordIngressIfNew(packet: replay, linkID: attackerLink))
|
||||
let rebindGate = VerifiedDirectRebindGate()
|
||||
ble._test_afterVerifiedDirectRebindEnqueued = rebindGate.pause
|
||||
defer {
|
||||
rebindGate.release()
|
||||
ble._test_afterVerifiedDirectRebindEnqueued = nil
|
||||
}
|
||||
ble._test_handlePacket(replay, fromPeerID: victimPeerID, preseedPeer: false)
|
||||
|
||||
let rebound = await TestHelpers.waitUntil(
|
||||
{ ble._test_centralBinding(attackerLink) == victimPeerID },
|
||||
let announcePaused = await TestHelpers.waitUntil(
|
||||
{ rebindGate.hasPaused },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(rebound)
|
||||
#expect(ble.canDeliverSecurely(to: victimPeerID))
|
||||
try #require(announcePaused)
|
||||
|
||||
// Rebind and ordinary reconnect preparation are one bleQueue
|
||||
// critical section. Once the binding is visible, stale sending keys
|
||||
// must already be unavailable.
|
||||
#expect(ble._test_centralBinding(attackerLink) == victimPeerID)
|
||||
#expect(!ble.canDeliverSecurely(to: victimPeerID))
|
||||
rebindGate.release()
|
||||
|
||||
let outbound = OutboundPacketTap()
|
||||
ble._test_onOutboundPacket = { outbound.record($0) }
|
||||
@@ -529,6 +541,113 @@ struct BLEServiceCoreTests {
|
||||
#expect(outbound.count(ofType: .courierEnvelope) == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func failedInboundReconnectRestoresAndDrainsTypedPayloadQueue() async throws {
|
||||
let ble = makeService()
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||
|
||||
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)
|
||||
await ble._test_drainNoiseMessagePipeline()
|
||||
#expect(ble.canDeliverSecurely(to: alicePeerID))
|
||||
|
||||
let outbound = OutboundPacketTap()
|
||||
ble._test_onOutboundPacket = outbound.record
|
||||
let forgedMessage1 = try mallory.initiateHandshake(with: ble.myPeerID)
|
||||
let firstPacket = BitchatPacket(
|
||||
type: MessageType.noiseHandshake.rawValue,
|
||||
senderID: Data(hexString: alicePeerID.id) ?? Data(),
|
||||
recipientID: Data(hexString: ble.myPeerID.id),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1_000),
|
||||
payload: forgedMessage1,
|
||||
signature: nil,
|
||||
ttl: 7
|
||||
)
|
||||
ble._test_handlePacket(firstPacket, fromPeerID: alicePeerID)
|
||||
|
||||
let responseReady = await TestHelpers.waitUntil(
|
||||
{
|
||||
outbound.snapshot().contains {
|
||||
$0.type == MessageType.noiseHandshake.rawValue
|
||||
}
|
||||
},
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
try #require(responseReady)
|
||||
let forgedMessage2 = try #require(
|
||||
outbound.snapshot().first {
|
||||
$0.type == MessageType.noiseHandshake.rawValue
|
||||
}?.payload
|
||||
)
|
||||
#expect(!ble.canDeliverSecurely(to: alicePeerID))
|
||||
|
||||
// Typed control traffic must queue behind the ordinary responder,
|
||||
// rather than attempting encryption and disappearing.
|
||||
let privateMessageID = "quarantine-pm-\(UUID().uuidString)"
|
||||
ble.sendPrivateMessage(
|
||||
"queued private message",
|
||||
to: alicePeerID,
|
||||
recipientNickname: "Alice",
|
||||
messageID: privateMessageID
|
||||
)
|
||||
ble.sendGroupInvite(Data("queued-during-quarantine".utf8), to: alicePeerID)
|
||||
await ble._test_drainNoiseMessagePipeline()
|
||||
#expect(outbound.count(ofType: .noiseEncrypted) == 0)
|
||||
|
||||
let forgedMessage3 = try #require(
|
||||
try mallory.processHandshakeMessage(
|
||||
from: ble.myPeerID,
|
||||
message: forgedMessage2
|
||||
)
|
||||
)
|
||||
let thirdPacket = BitchatPacket(
|
||||
type: MessageType.noiseHandshake.rawValue,
|
||||
senderID: Data(hexString: alicePeerID.id) ?? Data(),
|
||||
recipientID: Data(hexString: ble.myPeerID.id),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1_000) + 1,
|
||||
payload: forgedMessage3,
|
||||
signature: nil,
|
||||
ttl: 7
|
||||
)
|
||||
ble._test_handlePacket(thirdPacket, fromPeerID: alicePeerID)
|
||||
|
||||
// Restore re-enters the generation-bound authentication transition:
|
||||
// authenticated state and both outbound queues drain exactly once.
|
||||
let drained = await TestHelpers.waitUntil(
|
||||
{ outbound.count(ofType: .noiseEncrypted) >= 3 },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
try #require(drained)
|
||||
await ble._test_drainNoiseMessagePipeline()
|
||||
let plaintexts = try outbound.snapshot()
|
||||
.filter { $0.type == MessageType.noiseEncrypted.rawValue }
|
||||
.map { try alice.decrypt($0.payload, from: ble.myPeerID) }
|
||||
#expect(plaintexts.count == 3)
|
||||
#expect(
|
||||
plaintexts.filter {
|
||||
$0.first == NoisePayloadType.authenticatedPeerState.rawValue
|
||||
}.count == 1
|
||||
)
|
||||
#expect(
|
||||
plaintexts.filter {
|
||||
$0.first == NoisePayloadType.privateMessage.rawValue
|
||||
}.count == 1
|
||||
)
|
||||
#expect(
|
||||
plaintexts.filter {
|
||||
$0.first == NoisePayloadType.groupInvite.rawValue
|
||||
}.count == 1
|
||||
)
|
||||
}
|
||||
|
||||
/// 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 +973,40 @@ private final class OutboundPacketTap {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return packets.filter { $0.type == type.rawValue }.count
|
||||
}
|
||||
|
||||
func snapshot() -> [BitchatPacket] {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return packets
|
||||
}
|
||||
}
|
||||
|
||||
private final class VerifiedDirectRebindGate: @unchecked Sendable {
|
||||
private let condition = NSCondition()
|
||||
private var paused = false
|
||||
private var released = false
|
||||
|
||||
var hasPaused: Bool {
|
||||
condition.lock()
|
||||
defer { condition.unlock() }
|
||||
return paused
|
||||
}
|
||||
|
||||
func pause() {
|
||||
condition.lock()
|
||||
paused = true
|
||||
condition.broadcast()
|
||||
while !released {
|
||||
condition.wait()
|
||||
}
|
||||
condition.unlock()
|
||||
}
|
||||
|
||||
func release() {
|
||||
condition.lock()
|
||||
released = true
|
||||
condition.broadcast()
|
||||
condition.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
private final class ReceivePacketHandoffGate: @unchecked Sendable {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("BLE Noise reconnect policy")
|
||||
struct BLENoiseReconnectPolicyTests {
|
||||
@Test("Revalidation requires a cached session and no authenticated link")
|
||||
func revalidationPreconditions() {
|
||||
var policy = BLENoiseReconnectPolicy()
|
||||
let link = BLEIngressLinkID.peripheral("peripheral-a")
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
|
||||
let withoutSession = policy.shouldRevalidate(
|
||||
on: link,
|
||||
hasEstablishedSession: false,
|
||||
isNoiseAuthenticatedLink: false,
|
||||
hasAuthenticatedPeerLink: false,
|
||||
now: now
|
||||
)
|
||||
#expect(!withoutSession)
|
||||
let authenticated = policy.shouldRevalidate(
|
||||
on: link,
|
||||
hasEstablishedSession: true,
|
||||
isNoiseAuthenticatedLink: true,
|
||||
hasAuthenticatedPeerLink: true,
|
||||
now: now
|
||||
)
|
||||
#expect(!authenticated)
|
||||
let eligible = policy.shouldRevalidate(
|
||||
on: link,
|
||||
hasEstablishedSession: true,
|
||||
isNoiseAuthenticatedLink: false,
|
||||
hasAuthenticatedPeerLink: false,
|
||||
now: now
|
||||
)
|
||||
#expect(eligible)
|
||||
}
|
||||
|
||||
@Test("Revalidation is once per link epoch or after sixty seconds")
|
||||
func revalidationIsBoundPerLinkEpoch() {
|
||||
var policy = BLENoiseReconnectPolicy()
|
||||
let link = BLEIngressLinkID.central("central-a")
|
||||
let start = Date(timeIntervalSince1970: 2_000)
|
||||
|
||||
let initial = policy.shouldRevalidate(
|
||||
on: link,
|
||||
hasEstablishedSession: true,
|
||||
isNoiseAuthenticatedLink: false,
|
||||
hasAuthenticatedPeerLink: false,
|
||||
now: start
|
||||
)
|
||||
#expect(initial)
|
||||
let duringCooldown = policy.shouldRevalidate(
|
||||
on: link,
|
||||
hasEstablishedSession: true,
|
||||
isNoiseAuthenticatedLink: false,
|
||||
hasAuthenticatedPeerLink: false,
|
||||
now: start.addingTimeInterval(59.999)
|
||||
)
|
||||
#expect(!duringCooldown)
|
||||
let afterCooldown = policy.shouldRevalidate(
|
||||
on: link,
|
||||
hasEstablishedSession: true,
|
||||
isNoiseAuthenticatedLink: false,
|
||||
hasAuthenticatedPeerLink: false,
|
||||
now: start.addingTimeInterval(60)
|
||||
)
|
||||
#expect(afterCooldown)
|
||||
|
||||
policy.endLinkEpoch(link)
|
||||
let nextEpoch = policy.shouldRevalidate(
|
||||
on: link,
|
||||
hasEstablishedSession: true,
|
||||
isNoiseAuthenticatedLink: false,
|
||||
hasAuthenticatedPeerLink: false,
|
||||
now: start.addingTimeInterval(60.001)
|
||||
)
|
||||
#expect(nextEpoch)
|
||||
}
|
||||
|
||||
@Test("An authenticated sibling suppresses redundant reconnect")
|
||||
func authenticatedSiblingSuppressesReconnect() {
|
||||
var policy = BLENoiseReconnectPolicy()
|
||||
let link = BLEIngressLinkID.peripheral("unproven-sibling")
|
||||
let start = Date(timeIntervalSince1970: 3_000)
|
||||
|
||||
let suppressed = policy.shouldRevalidate(
|
||||
on: link,
|
||||
hasEstablishedSession: true,
|
||||
isNoiseAuthenticatedLink: false,
|
||||
hasAuthenticatedPeerLink: true,
|
||||
now: start
|
||||
)
|
||||
#expect(!suppressed)
|
||||
let eligible = policy.shouldRevalidate(
|
||||
on: link,
|
||||
hasEstablishedSession: true,
|
||||
isNoiseAuthenticatedLink: false,
|
||||
hasAuthenticatedPeerLink: false,
|
||||
now: start
|
||||
)
|
||||
#expect(eligible)
|
||||
}
|
||||
|
||||
@Test("Reserved replacement bit is not advertised")
|
||||
func reservedReplacementBitIsNotAdvertised() {
|
||||
#expect(
|
||||
!PeerCapabilities.localSupported.contains(
|
||||
.nonDestructiveNoiseReplacement
|
||||
)
|
||||
)
|
||||
#expect(PeerCapabilities.localSupported.contains(.privateMedia))
|
||||
}
|
||||
}
|
||||
@@ -171,8 +171,8 @@ struct NoiseEncryptionServiceTests {
|
||||
#expect(!emittedAuthentication)
|
||||
}
|
||||
|
||||
@Test("Failed forged replacement preserves the established peer session")
|
||||
func forgedReplacementPreservesEstablishedSession() async throws {
|
||||
@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())
|
||||
@@ -195,20 +195,20 @@ struct NoiseEncryptionServiceTests {
|
||||
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))
|
||||
// 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 replacement to fail peer binding")
|
||||
Issue.record("Expected forged reconnect to fail peer binding")
|
||||
} catch let error as NoiseSessionError {
|
||||
#expect(error == .peerIdentityMismatch)
|
||||
} catch {
|
||||
Issue.record("Unexpected replacement error: \(error)")
|
||||
Issue.record("Unexpected reconnect error: \(error)")
|
||||
}
|
||||
|
||||
#expect(receiver.hasEstablishedSession(with: alicePeerID))
|
||||
@@ -221,8 +221,8 @@ struct NoiseEncryptionServiceTests {
|
||||
#expect(!emittedReplacementAuthentication)
|
||||
}
|
||||
|
||||
@Test("Valid rehandshake atomically replaces the established session")
|
||||
func validRehandshakeReplacesEstablishedSession() throws {
|
||||
@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())
|
||||
@@ -235,7 +235,7 @@ struct NoiseEncryptionServiceTests {
|
||||
let message2 = try #require(
|
||||
try receiver.processHandshakeMessage(from: alicePeerID, message: message1)
|
||||
)
|
||||
#expect(receiver.hasEstablishedSession(with: alicePeerID))
|
||||
#expect(!receiver.hasEstablishedSession(with: alicePeerID))
|
||||
let message3 = try #require(
|
||||
try alice.processHandshakeMessage(from: receiverPeerID, message: message2)
|
||||
)
|
||||
@@ -353,6 +353,226 @@ struct NoiseEncryptionServiceTests {
|
||||
#expect(decrypted == typedPayload)
|
||||
}
|
||||
|
||||
@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("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())
|
||||
|
||||
@@ -28,6 +28,11 @@ public struct PeerCapabilities: OptionSet, Equatable, Hashable, Sendable {
|
||||
/// before outer BLE fragmentation. Peers that omit this bit require the
|
||||
/// signed directed raw-file migration fallback.
|
||||
public static let privateMedia = PeerCapabilities(rawValue: 1 << 8)
|
||||
/// Reserved for test builds that briefly advertised non-destructive Noise
|
||||
/// replacement. Current clients intentionally do not advertise or act on
|
||||
/// this bit; keep it decodable so the wire assignment is never reused.
|
||||
public static let nonDestructiveNoiseReplacement =
|
||||
PeerCapabilities(rawValue: 1 << 10)
|
||||
|
||||
/// Minimal little-endian byte encoding; always at least one byte so an
|
||||
/// empty set is distinguishable from an absent TLV.
|
||||
|
||||
@@ -20,6 +20,10 @@ struct PeerCapabilitiesTests {
|
||||
|
||||
let high = PeerCapabilities(rawValue: 1 << 9)
|
||||
#expect(high.encoded() == Data([0x00, 0x02]))
|
||||
#expect(
|
||||
PeerCapabilities.nonDestructiveNoiseReplacement.encoded()
|
||||
== Data([0x00, 0x04])
|
||||
)
|
||||
|
||||
let all: PeerCapabilities = [.prekeys, .wifiBulk, .gateway, .groups, .board, .vouch, .meshDiagnostics, .privateMedia]
|
||||
#expect(PeerCapabilities(encoded: all.encoded()) == all)
|
||||
|
||||
Reference in New Issue
Block a user