mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 13:25:20 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7551a3046 |
@@ -39,17 +39,15 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
@Published private(set) var messages: [BitchatMessage] = []
|
@Published private(set) var messages: [BitchatMessage] = []
|
||||||
@Published private(set) var isUnread: Bool = false
|
@Published private(set) var isUnread: Bool = false
|
||||||
|
|
||||||
/// Incrementally-maintained message-ID → logical-index map for O(1)
|
/// Incrementally-maintained message-ID → index map for O(1) dedup and
|
||||||
/// dedup and delivery-status lookup. Logical indexes are physical array
|
/// delivery-status lookup. Kept in sync on every mutation:
|
||||||
/// indexes plus `indexOffset`; trimming from the head advances the offset
|
/// - tail append: single insert
|
||||||
/// instead of rewriting every surviving dictionary entry. This matters
|
/// - out-of-order insert: suffix reindex from the insertion point
|
||||||
/// after the 1337-message cap is reached, when every steady-state tail
|
/// - trim: full rebuild — `removeFirst(k)` is already O(n), so the
|
||||||
/// append evicts one old row.
|
/// rebuild does not change the asymptotics, and trim only happens once
|
||||||
///
|
/// the cap (1337) is reached. Simple and correct beats the
|
||||||
/// Out-of-order inserts and middle removals still reindex only the
|
/// offset-tracking alternative here.
|
||||||
/// affected suffix. Full filtering resets the offset while rebuilding.
|
|
||||||
private var indexByMessageID: [String: Int] = [:]
|
private var indexByMessageID: [String: Int] = [:]
|
||||||
private var indexOffset = 0
|
|
||||||
|
|
||||||
fileprivate init(id: ConversationID, cap: Int) {
|
fileprivate init(id: ConversationID, cap: Int) {
|
||||||
self.id = id
|
self.id = id
|
||||||
@@ -63,7 +61,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func message(withID messageID: String) -> BitchatMessage? {
|
func message(withID messageID: String) -> BitchatMessage? {
|
||||||
guard let index = physicalIndex(forMessageID: messageID) else { return nil }
|
guard let index = indexByMessageID[messageID] else { return nil }
|
||||||
return messages[index]
|
return messages[index]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +101,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
reindex(from: index)
|
reindex(from: index)
|
||||||
} else {
|
} else {
|
||||||
messages.append(message)
|
messages.append(message)
|
||||||
indexByMessageID[message.id] = indexOffset + messages.count - 1
|
indexByMessageID[message.id] = messages.count - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
return InsertResult(inserted: true, trimmedMessageIDs: trimIfNeeded())
|
return InsertResult(inserted: true, trimmedMessageIDs: trimIfNeeded())
|
||||||
@@ -113,7 +111,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
/// timeline position (in-place updates like media progress reuse the
|
/// timeline position (in-place updates like media progress reuse the
|
||||||
/// original timestamp); a new message goes through ordered insertion.
|
/// original timestamp); a new message goes through ordered insertion.
|
||||||
fileprivate func upsert(_ message: BitchatMessage) -> UpsertOutcome {
|
fileprivate func upsert(_ message: BitchatMessage) -> UpsertOutcome {
|
||||||
if let index = physicalIndex(forMessageID: message.id) {
|
if let index = indexByMessageID[message.id] {
|
||||||
messages[index] = message
|
messages[index] = message
|
||||||
return .updated
|
return .updated
|
||||||
}
|
}
|
||||||
@@ -127,7 +125,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
/// `.read` is never downgraded to `.delivered` or `.sent`.
|
/// `.read` is never downgraded to `.delivered` or `.sent`.
|
||||||
/// Returns `true` when the status was applied.
|
/// Returns `true` when the status was applied.
|
||||||
fileprivate func applyDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
fileprivate func applyDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
||||||
guard let index = physicalIndex(forMessageID: messageID) else { return false }
|
guard let index = indexByMessageID[messageID] else { return false }
|
||||||
let message = messages[index]
|
let message = messages[index]
|
||||||
guard !Self.shouldSkipStatusUpdate(current: message.deliveryStatus, new: status) else { return false }
|
guard !Self.shouldSkipStatusUpdate(current: message.deliveryStatus, new: status) else { return false }
|
||||||
|
|
||||||
@@ -144,7 +142,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
/// observers still need an @Published emission to re-render.
|
/// observers still need an @Published emission to re-render.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
fileprivate func republishMessage(withID messageID: String) -> Bool {
|
fileprivate func republishMessage(withID messageID: String) -> Bool {
|
||||||
guard let index = physicalIndex(forMessageID: messageID) else { return false }
|
guard let index = indexByMessageID[messageID] else { return false }
|
||||||
messages[index] = messages[index]
|
messages[index] = messages[index]
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -159,14 +157,10 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
/// Removes a single message by ID. Returns the removed message, or
|
/// Removes a single message by ID. Returns the removed message, or
|
||||||
/// `nil` when no message with that ID exists.
|
/// `nil` when no message with that ID exists.
|
||||||
fileprivate func remove(messageID: String) -> BitchatMessage? {
|
fileprivate func remove(messageID: String) -> BitchatMessage? {
|
||||||
guard let index = physicalIndex(forMessageID: messageID) else { return nil }
|
guard let index = indexByMessageID[messageID] else { return nil }
|
||||||
let removed = messages.remove(at: index)
|
let removed = messages.remove(at: index)
|
||||||
indexByMessageID.removeValue(forKey: messageID)
|
indexByMessageID.removeValue(forKey: messageID)
|
||||||
if index == 0 {
|
|
||||||
indexOffset += 1
|
|
||||||
} else {
|
|
||||||
reindex(from: index)
|
reindex(from: index)
|
||||||
}
|
|
||||||
return removed
|
return removed
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,7 +177,6 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
for id in removedIDs {
|
for id in removedIDs {
|
||||||
indexByMessageID.removeValue(forKey: id)
|
indexByMessageID.removeValue(forKey: id)
|
||||||
}
|
}
|
||||||
indexOffset = 0
|
|
||||||
reindex(from: 0)
|
reindex(from: 0)
|
||||||
return removedIDs
|
return removedIDs
|
||||||
}
|
}
|
||||||
@@ -191,7 +184,6 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
fileprivate func clearMessages() {
|
fileprivate func clearMessages() {
|
||||||
messages.removeAll()
|
messages.removeAll()
|
||||||
indexByMessageID.removeAll()
|
indexByMessageID.removeAll()
|
||||||
indexOffset = 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Diagnostics
|
// MARK: Diagnostics
|
||||||
@@ -213,10 +205,9 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
let message = messages[position]
|
let message = messages[position]
|
||||||
// Count equality + every message resolving to its own position
|
// Count equality + every message resolving to its own position
|
||||||
// proves the index is exactly the inverse map (no stale extras).
|
// proves the index is exactly the inverse map (no stale extras).
|
||||||
if let logicalIndex = indexByMessageID[message.id] {
|
if let index = indexByMessageID[message.id] {
|
||||||
let expectedIndex = indexOffset + position
|
if index != position {
|
||||||
if logicalIndex != expectedIndex {
|
violations.append("\(label): message \(message.id.prefix(8))… at \(position) indexed at \(index)")
|
||||||
violations.append("\(label): message \(message.id.prefix(8))… at \(position) indexed at \(logicalIndex - indexOffset)")
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
violations.append("\(label): message \(message.id.prefix(8))… at \(position) missing from index")
|
violations.append("\(label): message \(message.id.prefix(8))… at \(position) missing from index")
|
||||||
@@ -278,17 +269,10 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
|
|
||||||
private func reindex(from start: Int) {
|
private func reindex(from start: Int) {
|
||||||
for index in start..<messages.count {
|
for index in start..<messages.count {
|
||||||
indexByMessageID[messages[index].id] = indexOffset + index
|
indexByMessageID[messages[index].id] = index
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func physicalIndex(forMessageID messageID: String) -> Int? {
|
|
||||||
guard let logicalIndex = indexByMessageID[messageID] else { return nil }
|
|
||||||
let index = logicalIndex - indexOffset
|
|
||||||
guard messages.indices.contains(index) else { return nil }
|
|
||||||
return index
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Trims oldest messages over the cap; returns the trimmed message IDs.
|
/// Trims oldest messages over the cap; returns the trimmed message IDs.
|
||||||
private func trimIfNeeded() -> [String] {
|
private func trimIfNeeded() -> [String] {
|
||||||
guard messages.count > cap else { return [] }
|
guard messages.count > cap else { return [] }
|
||||||
@@ -298,7 +282,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
indexByMessageID.removeValue(forKey: id)
|
indexByMessageID.removeValue(forKey: id)
|
||||||
}
|
}
|
||||||
messages.removeFirst(overflow)
|
messages.removeFirst(overflow)
|
||||||
indexOffset += overflow
|
reindex(from: 0)
|
||||||
return trimmedIDs
|
return trimmedIDs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -860,8 +844,8 @@ extension Conversation {
|
|||||||
/// (positions 0 and 1 swap their index entries). Requires >= 2 messages.
|
/// (positions 0 and 1 swap their index entries). Requires >= 2 messages.
|
||||||
func _testCorruptIndexEntries() {
|
func _testCorruptIndexEntries() {
|
||||||
guard messages.count >= 2 else { return }
|
guard messages.count >= 2 else { return }
|
||||||
indexByMessageID[messages[0].id] = indexOffset + 1
|
indexByMessageID[messages[0].id] = 1
|
||||||
indexByMessageID[messages[1].id] = indexOffset
|
indexByMessageID[messages[1].id] = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drops a message's index entry entirely (count mismatch + missing).
|
/// Drops a message's index entry entirely (count mismatch + missing).
|
||||||
@@ -875,8 +859,8 @@ extension Conversation {
|
|||||||
func _testCorruptOrderingPreservingIndex() {
|
func _testCorruptOrderingPreservingIndex() {
|
||||||
guard messages.count >= 2 else { return }
|
guard messages.count >= 2 else { return }
|
||||||
messages.swapAt(0, messages.count - 1)
|
messages.swapAt(0, messages.count - 1)
|
||||||
indexByMessageID[messages[0].id] = indexOffset
|
indexByMessageID[messages[0].id] = 0
|
||||||
indexByMessageID[messages[messages.count - 1].id] = indexOffset + messages.count - 1
|
indexByMessageID[messages[messages.count - 1].id] = messages.count - 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -916,7 +900,7 @@ extension ConversationStore {
|
|||||||
extension Conversation {
|
extension Conversation {
|
||||||
fileprivate func _testAppendBypassingTrim(_ message: BitchatMessage) {
|
fileprivate func _testAppendBypassingTrim(_ message: BitchatMessage) {
|
||||||
messages.append(message)
|
messages.append(message)
|
||||||
indexByMessageID[message.id] = indexOffset + messages.count - 1
|
indexByMessageID[message.id] = messages.count - 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ enum NoiseSecurityConstants {
|
|||||||
// Maximum handshake message size
|
// Maximum handshake message size
|
||||||
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
|
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
|
// Session timeout - sessions older than this should be renegotiated
|
||||||
static let sessionTimeout: TimeInterval = 86400 // 24 hours
|
static let sessionTimeout: TimeInterval = 86400 // 24 hours
|
||||||
|
|
||||||
|
|||||||
@@ -11,4 +11,5 @@ enum NoiseSessionError: Error, Equatable {
|
|||||||
case notEstablished
|
case notEstablished
|
||||||
case sessionNotFound
|
case sessionNotFound
|
||||||
case alreadyEstablished
|
case alreadyEstablished
|
||||||
|
case peerIdentityMismatch
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ import BitFoundation
|
|||||||
|
|
||||||
final class NoiseSessionManager {
|
final class NoiseSessionManager {
|
||||||
private var sessions: [PeerID: NoiseSession] = [:]
|
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 sessionFactory: (PeerID, NoiseRole) -> NoiseSession
|
||||||
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
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) {
|
if let session = sessions.removeValue(forKey: peerID) {
|
||||||
session.reset() // Clear sensitive data before removing
|
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 {
|
for (_, session) in sessions {
|
||||||
session.reset()
|
session.reset()
|
||||||
}
|
}
|
||||||
|
for (_, candidate) in responderCandidates {
|
||||||
|
candidate.reset()
|
||||||
|
}
|
||||||
sessions.removeAll()
|
sessions.removeAll()
|
||||||
|
responderCandidates.removeAll()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +91,7 @@ final class NoiseSessionManager {
|
|||||||
// Remove any existing non-established session
|
// Remove any existing non-established session
|
||||||
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
|
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
existingSession.reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new initiator session
|
// Create new initiator session
|
||||||
@@ -91,6 +104,7 @@ final class NoiseSessionManager {
|
|||||||
} catch {
|
} catch {
|
||||||
// Clean up failed session
|
// Clean up failed session
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
session.reset()
|
||||||
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
|
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
@@ -100,39 +114,50 @@ final class NoiseSessionManager {
|
|||||||
func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
|
func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
|
||||||
// Process everything within the synchronized block to prevent race conditions
|
// Process everything within the synchronized block to prevent race conditions
|
||||||
return try managerQueue.sync(flags: .barrier) {
|
return try managerQueue.sync(flags: .barrier) {
|
||||||
var shouldCreateNew = false
|
|
||||||
var existingSession: NoiseSession? = nil
|
|
||||||
|
|
||||||
if let existing = sessions[peerID] {
|
|
||||||
// If we have an established session, the peer must have cleared their session
|
|
||||||
// for a good reason (e.g., decryption failure, restart, etc.)
|
|
||||||
// We should accept the new handshake to re-establish encryption
|
|
||||||
if existing.isEstablished() {
|
|
||||||
SecureLogger.info("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session", category: .session)
|
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
|
||||||
shouldCreateNew = true
|
|
||||||
} 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
shouldCreateNew = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get or create session
|
|
||||||
let session: NoiseSession
|
let session: NoiseSession
|
||||||
if shouldCreateNew {
|
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.
|
||||||
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
existing.reset()
|
||||||
|
let replacement = sessionFactory(peerID, .responder)
|
||||||
|
sessions[peerID] = replacement
|
||||||
|
session = replacement
|
||||||
|
isReplacementCandidate = false
|
||||||
|
} else {
|
||||||
|
session = existing
|
||||||
|
isReplacementCandidate = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
let newSession = sessionFactory(peerID, .responder)
|
let newSession = sessionFactory(peerID, .responder)
|
||||||
sessions[peerID] = newSession
|
sessions[peerID] = newSession
|
||||||
session = newSession
|
session = newSession
|
||||||
} else {
|
isReplacementCandidate = false
|
||||||
session = existingSession!
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process the handshake message within the synchronized block
|
// Process the handshake message within the synchronized block
|
||||||
@@ -141,18 +166,40 @@ final class NoiseSessionManager {
|
|||||||
|
|
||||||
// Check if session is established after processing
|
// Check if session is established after processing
|
||||||
if session.isEstablished() {
|
if session.isEstablished() {
|
||||||
if let remoteKey = session.getRemoteStaticPublicKey() {
|
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
|
// Schedule callback outside the synchronized block to prevent deadlock
|
||||||
DispatchQueue.global().async { [weak self] in
|
DispatchQueue.global().async { [weak self] in
|
||||||
self?.onSessionEstablished?(peerID, remoteKey)
|
self?.onSessionEstablished?(peerID, remoteKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return response
|
return response
|
||||||
} catch {
|
} catch {
|
||||||
// Reset the session on handshake failure so next attempt can start fresh
|
// 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)
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
}
|
||||||
|
session.reset()
|
||||||
|
|
||||||
// Schedule callback outside the synchronized block to prevent deadlock
|
// Schedule callback outside the synchronized block to prevent deadlock
|
||||||
DispatchQueue.global().async { [weak self] in
|
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
|
// MARK: - Encryption/Decryption
|
||||||
|
|
||||||
func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data {
|
func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data {
|
||||||
|
|||||||
@@ -49,7 +49,11 @@ final class BLENoisePacketHandler {
|
|||||||
self.environment = environment
|
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
|
let env = environment
|
||||||
// Use NoiseEncryptionService for handshake processing
|
// Use NoiseEncryptionService for handshake processing
|
||||||
if PeerID(hexData: packet.recipientID) == env.localPeerID() {
|
if PeerID(hexData: packet.recipientID) == env.localPeerID() {
|
||||||
@@ -72,14 +76,26 @@ final class BLENoisePacketHandler {
|
|||||||
|
|
||||||
// Session establishment will trigger onPeerAuthenticated callback
|
// Session establishment will trigger onPeerAuthenticated callback
|
||||||
// which will send any pending messages at the right time
|
// 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 {
|
} catch {
|
||||||
SecureLogger.error("Failed to process handshake: \(error)")
|
SecureLogger.error("Failed to process handshake: \(error)")
|
||||||
// Try initiating a new handshake
|
// Try initiating a new handshake
|
||||||
if !env.hasNoiseSession(peerID) {
|
if !env.hasNoiseSession(peerID) {
|
||||||
env.initiateHandshake(peerID)
|
env.initiateHandshake(peerID)
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
|
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) {
|
_ = collectionsQueue.sync(flags: .barrier) {
|
||||||
// Remove the peer when they leave
|
// Remove the peer when they leave
|
||||||
peerRegistry.remove(peerID)
|
peerRegistry.remove(peerID)
|
||||||
@@ -1635,6 +1672,7 @@ final class BLEService: NSObject {
|
|||||||
self.deliverTransportEvent(.peerDisconnected(peerID))
|
self.deliverTransportEvent(.peerDisconnected(peerID))
|
||||||
self.deliverTransportEvent(.peerListUpdated(currentPeerIDs))
|
self.deliverTransportEvent(.peerListUpdated(currentPeerIDs))
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
private func sendAnnounce(forceSend: Bool = false) {
|
private func sendAnnounce(forceSend: Bool = false) {
|
||||||
// Throttle announces to prevent flooding
|
// 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) {
|
func _test_seedConnectedPeer(_ peerID: PeerID, nickname: String) {
|
||||||
collectionsQueue.sync(flags: .barrier) {
|
collectionsQueue.sync(flags: .barrier) {
|
||||||
peerRegistry.upsert(BLEPeerInfo(
|
peerRegistry.upsert(BLEPeerInfo(
|
||||||
@@ -4785,7 +4829,9 @@ extension BLEService {
|
|||||||
handleMeshPong(packet, from: senderID)
|
handleMeshPong(packet, from: senderID)
|
||||||
|
|
||||||
case .leave:
|
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:
|
case .none:
|
||||||
SecureLogger.warning("⚠️ Unknown message type: \(packet.type)", category: .session)
|
SecureLogger.warning("⚠️ Unknown message type: \(packet.type)", category: .session)
|
||||||
@@ -5426,8 +5472,14 @@ extension BLEService {
|
|||||||
|
|
||||||
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
|
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||||
let wasEstablished = noiseService.hasEstablishedSession(with: peerID)
|
let wasEstablished = noiseService.hasEstablishedSession(with: peerID)
|
||||||
noisePacketHandler.handleHandshake(packet, from: peerID)
|
let processed = noisePacketHandler.handleHandshake(packet, from: peerID)
|
||||||
if !wasEstablished, noiseService.hasEstablishedSession(with: 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)
|
markNoiseAuthenticatedIngressLink(for: packet, peerID: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,95 @@ struct BLEServiceCoreTests {
|
|||||||
#expect(ble.currentPeerSnapshots().isEmpty)
|
#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
|
@Test
|
||||||
func ingressAllowsRelayedSenderOnBoundLink() async throws {
|
func ingressAllowsRelayedSenderOnBoundLink() async throws {
|
||||||
let ble = makeService()
|
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 final class PublicCaptureDelegate: BitchatDelegate {
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private(set) var publicMessages: [BitchatMessage] = []
|
private(set) var publicMessages: [BitchatMessage] = []
|
||||||
|
|||||||
@@ -45,154 +45,6 @@ private func makeDirectConversationID(_ suffix: String) -> ConversationID {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deliberately simple O(n) model used to differentially test the store's
|
|
||||||
/// optimized logical-index bookkeeping. It models observable behavior only;
|
|
||||||
/// it has no offset or ID index and therefore cannot reproduce the same bug.
|
|
||||||
private struct ReferenceConversationTimeline {
|
|
||||||
struct Message: Equatable {
|
|
||||||
let id: String
|
|
||||||
let timestamp: Date
|
|
||||||
let content: String
|
|
||||||
var deliveryStatus: DeliveryStatus?
|
|
||||||
|
|
||||||
init(_ message: BitchatMessage) {
|
|
||||||
id = message.id
|
|
||||||
timestamp = message.timestamp
|
|
||||||
content = message.content
|
|
||||||
deliveryStatus = message.deliveryStatus
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AppendResult {
|
|
||||||
let inserted: Bool
|
|
||||||
let trimmedCount: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
let cap: Int
|
|
||||||
private(set) var messages: [Message] = []
|
|
||||||
|
|
||||||
func contains(_ id: String) -> Bool {
|
|
||||||
messages.contains { $0.id == id }
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func append(_ message: BitchatMessage) -> AppendResult {
|
|
||||||
guard !contains(message.id) else {
|
|
||||||
return AppendResult(inserted: false, trimmedCount: 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
let snapshot = Message(message)
|
|
||||||
var low = 0
|
|
||||||
var high = messages.count
|
|
||||||
while low < high {
|
|
||||||
let mid = (low + high) / 2
|
|
||||||
if messages[mid].timestamp <= snapshot.timestamp {
|
|
||||||
low = mid + 1
|
|
||||||
} else {
|
|
||||||
high = mid
|
|
||||||
}
|
|
||||||
}
|
|
||||||
messages.insert(snapshot, at: low)
|
|
||||||
|
|
||||||
let overflow = max(0, messages.count - cap)
|
|
||||||
if overflow > 0 {
|
|
||||||
messages.removeFirst(overflow)
|
|
||||||
}
|
|
||||||
return AppendResult(inserted: true, trimmedCount: overflow)
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func upsert(_ message: BitchatMessage) -> Int {
|
|
||||||
if let index = messages.firstIndex(where: { $0.id == message.id }) {
|
|
||||||
messages[index] = Message(message)
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return append(message).trimmedCount
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func applyDeliveryStatus(_ status: DeliveryStatus, to id: String) -> Bool {
|
|
||||||
guard let index = messages.firstIndex(where: { $0.id == id }),
|
|
||||||
messages[index].deliveryStatus != status else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
// The differential stream uses only unique `.delivered` values (or
|
|
||||||
// an exact repeat), so no-downgrade policy is intentionally outside
|
|
||||||
// this index-focused reference model.
|
|
||||||
messages[index].deliveryStatus = status
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func remove(at index: Int) -> Message {
|
|
||||||
messages.remove(at: index)
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func removeAll(where predicate: (Message) -> Bool) {
|
|
||||||
messages.removeAll(where: predicate)
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func clear() {
|
|
||||||
messages.removeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct ConversationStoreDifferentialRNG {
|
|
||||||
private var state: UInt64
|
|
||||||
|
|
||||||
init(seed: UInt64) {
|
|
||||||
state = seed
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func next() -> UInt64 {
|
|
||||||
state &+= 0x9E37_79B9_7F4A_7C15
|
|
||||||
var value = state
|
|
||||||
value = (value ^ (value >> 30)) &* 0xBF58_476D_1CE4_E5B9
|
|
||||||
value = (value ^ (value >> 27)) &* 0x94D0_49BB_1331_11EB
|
|
||||||
return value ^ (value >> 31)
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func index(upperBound: Int) -> Int {
|
|
||||||
precondition(upperBound > 0)
|
|
||||||
return Int(next() % UInt64(upperBound))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func expectStore(
|
|
||||||
_ store: ConversationStore,
|
|
||||||
matches reference: ReferenceConversationTimeline,
|
|
||||||
issuedIDs: [String],
|
|
||||||
checkpoint: String
|
|
||||||
) {
|
|
||||||
let conversation = store.conversation(for: .mesh)
|
|
||||||
let actual = conversation.messages.map(ReferenceConversationTimeline.Message.init)
|
|
||||||
#expect(actual == reference.messages, "timeline mismatch at \(checkpoint)")
|
|
||||||
|
|
||||||
let lookupSnapshot = reference.messages.compactMap { expected in
|
|
||||||
conversation.message(withID: expected.id).map(ReferenceConversationTimeline.Message.init)
|
|
||||||
}
|
|
||||||
#expect(lookupSnapshot == reference.messages, "ID lookup mismatch at \(checkpoint)")
|
|
||||||
#expect(
|
|
||||||
Set(conversation.messageIDs) == Set(reference.messages.map(\.id)),
|
|
||||||
"per-conversation ID set mismatch at \(checkpoint)"
|
|
||||||
)
|
|
||||||
|
|
||||||
if !reference.messages.isEmpty {
|
|
||||||
for index in Set([0, reference.messages.count / 2, reference.messages.count - 1]) {
|
|
||||||
let id = reference.messages[index].id
|
|
||||||
#expect(store.conversationIDs(forMessageID: id) == [.mesh], "store ID map mismatch at \(checkpoint)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let activeIDs = Set(reference.messages.map(\.id))
|
|
||||||
var checkedStaleIDs = 0
|
|
||||||
for id in issuedIDs.reversed() where !activeIDs.contains(id) {
|
|
||||||
#expect(conversation.message(withID: id) == nil, "stale conversation index entry at \(checkpoint)")
|
|
||||||
#expect(store.conversationIDs(forMessageID: id).isEmpty, "stale store ID map entry at \(checkpoint)")
|
|
||||||
checkedStaleIDs += 1
|
|
||||||
if checkedStaleIDs == 16 { break }
|
|
||||||
}
|
|
||||||
|
|
||||||
#expect(store.auditInvariants().isEmpty, "invariant audit failed at \(checkpoint)")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suite("ConversationStore")
|
@Suite("ConversationStore")
|
||||||
struct ConversationStoreTests {
|
struct ConversationStoreTests {
|
||||||
|
|
||||||
@@ -288,282 +140,6 @@ struct ConversationStoreTests {
|
|||||||
#expect(conversation.message(withID: probeID)?.deliveryStatus == .sent)
|
#expect(conversation.message(withID: probeID)?.deliveryStatus == .sent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("steady-state cap trimming keeps lookups exact across mixed mutations")
|
|
||||||
@MainActor
|
|
||||||
func steadyStateCapTrimmingKeepsLogicalIndexExact() {
|
|
||||||
let store = ConversationStore()
|
|
||||||
let conversation = store.conversation(for: .mesh)
|
|
||||||
let overflow = 64
|
|
||||||
|
|
||||||
for i in 0..<(conversation.cap + overflow) {
|
|
||||||
store.append(makeMessage(id: "m\(i)", timestamp: TimeInterval(i)), to: .mesh)
|
|
||||||
}
|
|
||||||
|
|
||||||
#expect(conversation.messages.first?.id == "m\(overflow)")
|
|
||||||
#expect(conversation.message(withID: "m\(overflow)")?.id == "m\(overflow)")
|
|
||||||
|
|
||||||
// Exercise a suffix reindex after the head offset has advanced, then
|
|
||||||
// trim the old head. The late row becomes the new first element.
|
|
||||||
let late = makeMessage(id: "late", timestamp: TimeInterval(overflow) + 0.5)
|
|
||||||
#expect(store.append(late, to: .mesh))
|
|
||||||
#expect(conversation.messages.first?.id == "late")
|
|
||||||
#expect(conversation.message(withID: "m\(overflow + 1)")?.id == "m\(overflow + 1)")
|
|
||||||
|
|
||||||
// Head and middle removals, an in-place upsert, and a status update
|
|
||||||
// must all resolve through the same logical index representation.
|
|
||||||
#expect(store.removeMessage(withID: "late", from: .mesh)?.id == "late")
|
|
||||||
let middleID = "m\(overflow + conversation.cap / 2)"
|
|
||||||
#expect(store.removeMessage(withID: middleID, from: .mesh)?.id == middleID)
|
|
||||||
|
|
||||||
let probeID = "m\(overflow + 10)"
|
|
||||||
store.upsertByID(
|
|
||||||
makeMessage(id: probeID, timestamp: TimeInterval(overflow + 10), content: "edited"),
|
|
||||||
in: .mesh
|
|
||||||
)
|
|
||||||
#expect(conversation.message(withID: probeID)?.content == "edited")
|
|
||||||
#expect(store.setDeliveryStatus(.sent, forMessageID: probeID, in: .mesh))
|
|
||||||
#expect(conversation.message(withID: probeID)?.deliveryStatus == .sent)
|
|
||||||
#expect(store.auditInvariants().isEmpty)
|
|
||||||
|
|
||||||
// Clearing resets the logical offset as well as the maps.
|
|
||||||
store.clear(.mesh)
|
|
||||||
#expect(store.append(makeMessage(id: "after-clear", timestamp: 10_000), to: .mesh))
|
|
||||||
#expect(conversation.message(withID: "after-clear")?.id == "after-clear")
|
|
||||||
#expect(store.auditInvariants().isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("logical index offset matches a reference model under adversarial mutations")
|
|
||||||
@MainActor
|
|
||||||
func logicalIndexOffsetDifferentialStress() async {
|
|
||||||
let store = ConversationStore()
|
|
||||||
let cap = store.conversation(for: .mesh).cap
|
|
||||||
var reference = ReferenceConversationTimeline(cap: cap)
|
|
||||||
var rng = ConversationStoreDifferentialRNG(seed: 0xC0FF_EE13_37CA_FE42)
|
|
||||||
var issuedIDs: [String] = []
|
|
||||||
var nextID = 0
|
|
||||||
var nextTailTimestamp: TimeInterval = 1_700_000_000
|
|
||||||
var trimmedCount = 0
|
|
||||||
|
|
||||||
var tailAppendCount = 0
|
|
||||||
var outOfOrderCount = 0
|
|
||||||
var duplicateOrReuseCount = 0
|
|
||||||
var headRemovalCount = 0
|
|
||||||
var middleRemovalCount = 0
|
|
||||||
var upsertCount = 0
|
|
||||||
var deliveryUpdateCount = 0
|
|
||||||
var filterCount = 0
|
|
||||||
var clearCount = 0
|
|
||||||
|
|
||||||
func issueMessage(timestamp: TimeInterval? = nil, tag: String) -> BitchatMessage {
|
|
||||||
let number = nextID
|
|
||||||
nextID += 1
|
|
||||||
let id = "diff-\(number)"
|
|
||||||
issuedIDs.append(id)
|
|
||||||
let resolvedTimestamp: TimeInterval
|
|
||||||
if let timestamp {
|
|
||||||
resolvedTimestamp = timestamp
|
|
||||||
} else {
|
|
||||||
resolvedTimestamp = nextTailTimestamp
|
|
||||||
nextTailTimestamp += 1
|
|
||||||
}
|
|
||||||
let dropMarker = number.isMultiple(of: 11) ? " [drop]" : ""
|
|
||||||
return makeMessage(
|
|
||||||
id: id,
|
|
||||||
timestamp: resolvedTimestamp,
|
|
||||||
content: "\(tag) \(number)\(dropMarker)"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
func appendAndCompare(_ message: BitchatMessage, checkpoint: String) -> ReferenceConversationTimeline.AppendResult {
|
|
||||||
let expected = reference.append(message)
|
|
||||||
let actual = store.append(message, to: .mesh)
|
|
||||||
#expect(actual == expected.inserted, "append result mismatch at \(checkpoint)")
|
|
||||||
trimmedCount += expected.trimmedCount
|
|
||||||
return expected
|
|
||||||
}
|
|
||||||
|
|
||||||
func refill(extra: Int, checkpoint: String) async {
|
|
||||||
let appendCount = max(0, cap - reference.messages.count) + extra
|
|
||||||
for index in 0..<appendCount {
|
|
||||||
appendAndCompare(
|
|
||||||
issueMessage(tag: "refill"),
|
|
||||||
checkpoint: "\(checkpoint)-\(index)"
|
|
||||||
)
|
|
||||||
if index.isMultiple(of: 64) {
|
|
||||||
await Task.yield()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
expectStore(store, matches: reference, issuedIDs: issuedIDs, checkpoint: checkpoint)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start well into steady state so the offset is already non-zero
|
|
||||||
// before any mixed operations begin.
|
|
||||||
await refill(extra: 384, checkpoint: "initial steady-state fill")
|
|
||||||
|
|
||||||
for step in 0..<1_200 {
|
|
||||||
if step == 300 || step == 900 {
|
|
||||||
store.removeMessages(from: .mesh) { $0.content.contains("[drop]") }
|
|
||||||
reference.removeAll { $0.content.contains("[drop]") }
|
|
||||||
filterCount += 1
|
|
||||||
expectStore(
|
|
||||||
store,
|
|
||||||
matches: reference,
|
|
||||||
issuedIDs: issuedIDs,
|
|
||||||
checkpoint: "filter at step \(step)"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if step == 600 {
|
|
||||||
store.clear(.mesh)
|
|
||||||
reference.clear()
|
|
||||||
clearCount += 1
|
|
||||||
expectStore(
|
|
||||||
store,
|
|
||||||
matches: reference,
|
|
||||||
issuedIDs: issuedIDs,
|
|
||||||
checkpoint: "clear at step \(step)"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch rng.index(upperBound: 100) {
|
|
||||||
case 0..<35:
|
|
||||||
appendAndCompare(issueMessage(tag: "tail"), checkpoint: "tail append \(step)")
|
|
||||||
tailAppendCount += 1
|
|
||||||
|
|
||||||
case 35..<55:
|
|
||||||
if reference.messages.isEmpty {
|
|
||||||
appendAndCompare(issueMessage(tag: "tail-fallback"), checkpoint: "OOO fallback \(step)")
|
|
||||||
} else {
|
|
||||||
let target = reference.messages[rng.index(upperBound: reference.messages.count)]
|
|
||||||
let jitter = [-0.25, 0.0, 0.25][rng.index(upperBound: 3)]
|
|
||||||
let timestamp = target.timestamp.timeIntervalSince1970 + jitter
|
|
||||||
appendAndCompare(
|
|
||||||
issueMessage(timestamp: timestamp, tag: "out-of-order"),
|
|
||||||
checkpoint: "out-of-order append \(step)"
|
|
||||||
)
|
|
||||||
outOfOrderCount += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
case 55..<65:
|
|
||||||
if issuedIDs.isEmpty {
|
|
||||||
appendAndCompare(issueMessage(tag: "reuse-fallback"), checkpoint: "reuse fallback \(step)")
|
|
||||||
} else {
|
|
||||||
let reusedID = issuedIDs[rng.index(upperBound: issuedIDs.count)]
|
|
||||||
let message = makeMessage(
|
|
||||||
id: reusedID,
|
|
||||||
timestamp: nextTailTimestamp,
|
|
||||||
content: "duplicate-or-trimmed-reuse \(step)"
|
|
||||||
)
|
|
||||||
nextTailTimestamp += 1
|
|
||||||
appendAndCompare(message, checkpoint: "duplicate or reuse \(step)")
|
|
||||||
duplicateOrReuseCount += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
case 65..<73:
|
|
||||||
if !reference.messages.isEmpty {
|
|
||||||
let expected = reference.remove(at: 0)
|
|
||||||
let actual = store.removeMessage(withID: expected.id, from: .mesh)
|
|
||||||
.map(ReferenceConversationTimeline.Message.init)
|
|
||||||
#expect(actual == expected, "head removal mismatch at step \(step)")
|
|
||||||
headRemovalCount += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
case 73..<81:
|
|
||||||
if !reference.messages.isEmpty {
|
|
||||||
let middleStart = reference.messages.count / 4
|
|
||||||
let middleWidth = max(1, reference.messages.count / 2)
|
|
||||||
let index = min(
|
|
||||||
reference.messages.count - 1,
|
|
||||||
middleStart + rng.index(upperBound: middleWidth)
|
|
||||||
)
|
|
||||||
let expected = reference.remove(at: index)
|
|
||||||
let actual = store.removeMessage(withID: expected.id, from: .mesh)
|
|
||||||
.map(ReferenceConversationTimeline.Message.init)
|
|
||||||
#expect(actual == expected, "middle removal mismatch at step \(step)")
|
|
||||||
middleRemovalCount += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
case 81..<90:
|
|
||||||
let message: BitchatMessage
|
|
||||||
if step.isMultiple(of: 4) || reference.messages.isEmpty {
|
|
||||||
let timestamp = reference.messages.isEmpty
|
|
||||||
? nil
|
|
||||||
: reference.messages[rng.index(upperBound: reference.messages.count)]
|
|
||||||
.timestamp.timeIntervalSince1970
|
|
||||||
message = issueMessage(timestamp: timestamp, tag: "upsert-new")
|
|
||||||
} else {
|
|
||||||
let current = reference.messages[rng.index(upperBound: reference.messages.count)]
|
|
||||||
message = makeMessage(
|
|
||||||
id: current.id,
|
|
||||||
timestamp: current.timestamp.timeIntervalSince1970,
|
|
||||||
content: "upsert-existing \(step)",
|
|
||||||
deliveryStatus: current.deliveryStatus
|
|
||||||
)
|
|
||||||
}
|
|
||||||
trimmedCount += reference.upsert(message)
|
|
||||||
store.upsertByID(message, in: .mesh)
|
|
||||||
upsertCount += 1
|
|
||||||
|
|
||||||
default:
|
|
||||||
let id: String
|
|
||||||
let repeatedStatus: DeliveryStatus?
|
|
||||||
if step.isMultiple(of: 6) || reference.messages.isEmpty {
|
|
||||||
id = "missing-\(step)"
|
|
||||||
repeatedStatus = nil
|
|
||||||
} else {
|
|
||||||
let current = reference.messages[rng.index(upperBound: reference.messages.count)]
|
|
||||||
id = current.id
|
|
||||||
repeatedStatus = current.deliveryStatus
|
|
||||||
}
|
|
||||||
let status: DeliveryStatus
|
|
||||||
if step.isMultiple(of: 4), let repeatedStatus {
|
|
||||||
status = repeatedStatus
|
|
||||||
} else {
|
|
||||||
status = .delivered(
|
|
||||||
to: "peer",
|
|
||||||
at: Date(timeIntervalSince1970: 2_000_000_000 + Double(step))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
let expected = reference.applyDeliveryStatus(status, to: id)
|
|
||||||
let actual = store.setDeliveryStatus(status, forMessageID: id, in: .mesh)
|
|
||||||
#expect(actual == expected, "delivery update mismatch at step \(step)")
|
|
||||||
deliveryUpdateCount += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
expectStore(
|
|
||||||
store,
|
|
||||||
matches: reference,
|
|
||||||
issuedIDs: issuedIDs,
|
|
||||||
checkpoint: "mixed operation \(step)"
|
|
||||||
)
|
|
||||||
|
|
||||||
// This intentionally expensive MainActor stress test runs beside
|
|
||||||
// async audio/UI tests in SwiftPM's parallel phase. Cooperatively
|
|
||||||
// release the actor so their bounded waits can make progress.
|
|
||||||
await Task.yield()
|
|
||||||
|
|
||||||
if (step + 1).isMultiple(of: 100) {
|
|
||||||
await refill(extra: 32, checkpoint: "periodic refill after step \(step)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Guarantee another long run of one-row evictions after every other
|
|
||||||
// mutation family has perturbed and rebuilt the offset/index state.
|
|
||||||
await refill(extra: 512, checkpoint: "final steady-state trim run")
|
|
||||||
|
|
||||||
#expect(trimmedCount > 1_200)
|
|
||||||
#expect(tailAppendCount > 300)
|
|
||||||
#expect(outOfOrderCount > 150)
|
|
||||||
#expect(duplicateOrReuseCount > 75)
|
|
||||||
#expect(headRemovalCount > 50)
|
|
||||||
#expect(middleRemovalCount > 50)
|
|
||||||
#expect(upsertCount > 75)
|
|
||||||
#expect(deliveryUpdateCount > 75)
|
|
||||||
#expect(filterCount == 2)
|
|
||||||
#expect(clearCount == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Upsert
|
// MARK: - Upsert
|
||||||
|
|
||||||
@Test("upsertByID replaces in place and appends when absent")
|
@Test("upsertByID replaces in place and appends when absent")
|
||||||
|
|||||||
@@ -12,8 +12,15 @@ struct NoiseCoverageTests {
|
|||||||
private let bobStaticKey = Curve25519.KeyAgreement.PrivateKey()
|
private let bobStaticKey = Curve25519.KeyAgreement.PrivateKey()
|
||||||
private let charlieStaticKey = Curve25519.KeyAgreement.PrivateKey()
|
private let charlieStaticKey = Curve25519.KeyAgreement.PrivateKey()
|
||||||
|
|
||||||
private let alicePeerID = PeerID(str: "0011223344556677")
|
// Manager test dictionaries are keyed by the remote peer. Keep the
|
||||||
private let bobPeerID = PeerID(str: "8899aabbccddeeff")
|
// 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")
|
private let charliePeerID = PeerID(str: "fedcba9876543210")
|
||||||
|
|
||||||
@Test("Protocol metadata and handshake patterns expose expected values")
|
@Test("Protocol metadata and handshake patterns expose expected values")
|
||||||
|
|||||||
@@ -501,62 +501,6 @@ final class PerformanceBaselineTests: XCTestCase {
|
|||||||
reportThroughput("store.append", samples: samples, operations: messageCount, unit: "messages")
|
reportThroughput("store.append", samples: samples, operations: messageCount, unit: "messages")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 7b. ConversationStore append at the retention cap
|
|
||||||
|
|
||||||
/// Steady-state public timeline traffic after the 1337-message retention
|
|
||||||
/// cap has been reached. Every tail append evicts the oldest row, which is
|
|
||||||
/// the long-lived workload the cold `store.append` benchmark does not
|
|
||||||
/// exercise.
|
|
||||||
func testConversationStoreSteadyStateAppend() {
|
|
||||||
let store = ConversationStore()
|
|
||||||
let cap = TransportConfig.meshTimelineCap
|
|
||||||
let messagesPerPass = 500
|
|
||||||
let base = Date(timeIntervalSince1970: 1_700_000_000)
|
|
||||||
|
|
||||||
for i in 0..<cap {
|
|
||||||
store.append(
|
|
||||||
BitchatMessage(
|
|
||||||
id: "perf-steady-seed-\(i)",
|
|
||||||
sender: "perfsender",
|
|
||||||
content: "steady-state seed \(i)",
|
|
||||||
timestamp: base.addingTimeInterval(Double(i)),
|
|
||||||
isRelay: false
|
|
||||||
),
|
|
||||||
to: .mesh
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
var pass = 0
|
|
||||||
var samples: [TimeInterval] = []
|
|
||||||
measure {
|
|
||||||
let startIndex = cap + pass * messagesPerPass
|
|
||||||
let start = Date()
|
|
||||||
for offset in 0..<messagesPerPass {
|
|
||||||
let i = startIndex + offset
|
|
||||||
store.append(
|
|
||||||
BitchatMessage(
|
|
||||||
id: "perf-steady-\(i)",
|
|
||||||
sender: "perfsender",
|
|
||||||
content: "steady-state message \(i)",
|
|
||||||
timestamp: base.addingTimeInterval(Double(i)),
|
|
||||||
isRelay: false
|
|
||||||
),
|
|
||||||
to: .mesh
|
|
||||||
)
|
|
||||||
}
|
|
||||||
samples.append(Date().timeIntervalSince(start))
|
|
||||||
pass += 1
|
|
||||||
XCTAssertEqual(store.conversation(for: .mesh).messages.count, cap)
|
|
||||||
}
|
|
||||||
|
|
||||||
reportThroughput(
|
|
||||||
"store.steadyStateAppend",
|
|
||||||
samples: samples,
|
|
||||||
operations: messagesPerPass,
|
|
||||||
unit: "messages"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 8. ConversationStore invariant audit (field observability)
|
// MARK: - 8. ConversationStore invariant audit (field observability)
|
||||||
|
|
||||||
/// `ConversationStore.auditInvariants()` over a realistic 5k-message
|
/// `ConversationStore.auditInvariants()` over a realistic 5k-message
|
||||||
|
|||||||
@@ -30,10 +30,6 @@
|
|||||||
"store.append": 213201,
|
"store.append": 213201,
|
||||||
"store.audit": 362
|
"store.audit": 362
|
||||||
},
|
},
|
||||||
"_reference_local_numbers_2026_07": {
|
|
||||||
"store.steadyStateAppend_before": 2315,
|
|
||||||
"store.steadyStateAppend": 53976
|
|
||||||
},
|
|
||||||
"floors": {
|
"floors": {
|
||||||
"nostrInbound.fresh": 450,
|
"nostrInbound.fresh": 450,
|
||||||
"nostrInbound.duplicate": 250000,
|
"nostrInbound.duplicate": 250000,
|
||||||
@@ -45,7 +41,6 @@
|
|||||||
"pipeline.privateIngest": 3000,
|
"pipeline.privateIngest": 3000,
|
||||||
"pipeline.publicIngest": 2400,
|
"pipeline.publicIngest": 2400,
|
||||||
"store.append": 48000,
|
"store.append": 48000,
|
||||||
"store.steadyStateAppend": 10000,
|
|
||||||
"store.audit": 70
|
"store.audit": 70
|
||||||
},
|
},
|
||||||
"_slowest_observed_ci_numbers_2026_06": {
|
"_slowest_observed_ci_numbers_2026_06": {
|
||||||
|
|||||||
@@ -152,6 +152,21 @@ struct BLENoisePacketHandlerTests {
|
|||||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
#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
|
// MARK: Encrypted
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -91,39 +91,150 @@ struct NoiseEncryptionServiceTests {
|
|||||||
func handshakeEncryptionAndFingerprintLifecycle() async throws {
|
func handshakeEncryptionAndFingerprintLifecycle() async throws {
|
||||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
let alicePeerID = PeerID(str: "0011223344556677")
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||||
let bobPeerID = PeerID(str: "8899aabbccddeeff")
|
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
||||||
let recorder = AuthenticationRecorder()
|
let recorder = AuthenticationRecorder()
|
||||||
|
|
||||||
#expect(alice.onPeerAuthenticated == nil)
|
#expect(alice.onPeerAuthenticated == nil)
|
||||||
alice.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:))
|
alice.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:))
|
||||||
bob.onPeerAuthenticated = 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)
|
let authenticated = await TestHelpers.waitUntil({ recorder.count >= 2 }, timeout: 5.0)
|
||||||
#expect(authenticated)
|
#expect(authenticated)
|
||||||
#expect(alice.hasEstablishedSession(with: alicePeerID))
|
#expect(alice.hasEstablishedSession(with: bobPeerID))
|
||||||
#expect(bob.hasEstablishedSession(with: bobPeerID))
|
#expect(bob.hasEstablishedSession(with: alicePeerID))
|
||||||
#expect(alice.hasSession(with: alicePeerID))
|
#expect(alice.hasSession(with: bobPeerID))
|
||||||
#expect(bob.hasSession(with: bobPeerID))
|
#expect(bob.hasSession(with: alicePeerID))
|
||||||
#expect(alice.getPeerPublicKeyData(alicePeerID)?.count == 32)
|
#expect(alice.getPeerPublicKeyData(bobPeerID)?.count == 32)
|
||||||
#expect(bob.getPeerPublicKeyData(bobPeerID)?.count == 32)
|
#expect(bob.getPeerPublicKeyData(alicePeerID)?.count == 32)
|
||||||
#expect(alice.getPeerFingerprint(alicePeerID) != nil)
|
#expect(alice.getPeerFingerprint(bobPeerID) != nil)
|
||||||
#expect(bob.getPeerFingerprint(bobPeerID) != nil)
|
#expect(bob.getPeerFingerprint(alicePeerID) != nil)
|
||||||
|
|
||||||
let plaintext = Data("secret payload".utf8)
|
let plaintext = Data("secret payload".utf8)
|
||||||
let ciphertext = try alice.encrypt(plaintext, for: alicePeerID)
|
let ciphertext = try alice.encrypt(plaintext, for: bobPeerID)
|
||||||
let decrypted = try bob.decrypt(ciphertext, from: bobPeerID)
|
let decrypted = try bob.decrypt(ciphertext, from: alicePeerID)
|
||||||
#expect(decrypted == plaintext)
|
#expect(decrypted == plaintext)
|
||||||
|
|
||||||
alice.clearSession(for: alicePeerID)
|
alice.clearSession(for: bobPeerID)
|
||||||
#expect(!alice.hasSession(with: alicePeerID))
|
#expect(!alice.hasSession(with: bobPeerID))
|
||||||
#expect(alice.getPeerFingerprint(alicePeerID) == nil)
|
#expect(alice.getPeerFingerprint(bobPeerID) == nil)
|
||||||
|
|
||||||
bob.clearEphemeralStateForPanic()
|
bob.clearEphemeralStateForPanic()
|
||||||
#expect(!bob.hasSession(with: bobPeerID))
|
#expect(!bob.hasSession(with: alicePeerID))
|
||||||
#expect(bob.getPeerFingerprint(bobPeerID) == nil)
|
#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")
|
@Test("Encrypt without a session requests handshake and decrypt without session fails")
|
||||||
@@ -200,16 +311,16 @@ struct NoiseEncryptionServiceTests {
|
|||||||
|
|
||||||
private func establishSessions(
|
private func establishSessions(
|
||||||
alice: NoiseEncryptionService,
|
alice: NoiseEncryptionService,
|
||||||
bob: NoiseEncryptionService,
|
bob: NoiseEncryptionService
|
||||||
alicePeerID: PeerID,
|
|
||||||
bobPeerID: PeerID
|
|
||||||
) throws {
|
) throws {
|
||||||
let message1 = try alice.initiateHandshake(with: alicePeerID)
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||||
let response = try bob.processHandshakeMessage(from: bobPeerID, message: message1)
|
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 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 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)
|
#expect(finalMessage == nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user