mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 09:05:20 +00:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5043dec2d | ||
|
|
2f8af04821 | ||
|
|
78083bd773 | ||
|
|
a8201b3958 | ||
|
|
01ce054238 | ||
|
|
b6c7c77080 | ||
|
|
55ee1df0bc | ||
|
|
b255355fed | ||
|
|
6e026c2c22 | ||
|
|
c8e330777a | ||
|
|
91aba8b597 | ||
|
|
d9a6dbfca8 | ||
|
|
68f8f03ad8 | ||
|
|
48026991b2 | ||
|
|
ec795520ee | ||
|
|
40238c5e43 | ||
|
|
a31cd80027 | ||
|
|
974510ad9e | ||
|
|
5aee7f0f98 | ||
|
|
6054248765 | ||
|
|
5f7df63238 | ||
|
|
b081c98dba | ||
|
|
76d3b0f1ed | ||
|
|
aa3021c9ca |
@@ -36,6 +36,30 @@ enum NoiseSecurityConstants {
|
||||
|
||||
// Noise XX message 1 contains only the initiator's 32-byte ephemeral key.
|
||||
static let xxInitialMessageSize = 32
|
||||
|
||||
// Bounds an ordinary initiator whose message 1 or 2 is lost.
|
||||
static let ordinaryHandshakeTimeout: TimeInterval = 10
|
||||
|
||||
// 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
|
||||
|
||||
// A released client may immediately retry after both crossed initiators
|
||||
// yielded. Give that unilateral retry a brief head start before the
|
||||
// patched side spends its one bounded recovery.
|
||||
static let handshakeCollisionRecoveryDelay: TimeInterval = 0.2
|
||||
|
||||
// Rate-limited recovery remains actionable without spinning.
|
||||
static let handshakeRateLimitRecoveryDelay: TimeInterval = 60
|
||||
|
||||
// Covers only reordering between a winning message 3 and the losing
|
||||
// crossed message 1.
|
||||
static let recentInitiatorCompletionGracePeriod: TimeInterval = 1
|
||||
|
||||
// After unauthenticated responder rollback, reject another attempt long
|
||||
// enough that paced message 1 traffic cannot keep outbound paused. A
|
||||
// legitimate peer converges through the one manager-owned local retry.
|
||||
static let ordinaryReconnectRollbackCooldown: TimeInterval = 60
|
||||
|
||||
// Session timeout - sessions older than this should be renegotiated
|
||||
static let sessionTimeout: TimeInterval = 86400 // 24 hours
|
||||
|
||||
@@ -13,3 +13,9 @@ enum NoiseSessionError: Error, Equatable {
|
||||
case alreadyEstablished
|
||||
case peerIdentityMismatch
|
||||
}
|
||||
|
||||
/// The manager owns the exact attempt's one bounded recovery. Packet handling
|
||||
/// must not launch its historical second, immediate restart for this failure.
|
||||
struct NoiseManagedHandshakeFailure: Error {
|
||||
let underlying: Error
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -154,3 +154,90 @@ struct BitchatFilePacket {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire-compatible identity for private media exchanged by clients using the
|
||||
/// current iOS entropy-bearing filenames, without extending the deployed file
|
||||
/// TLV. Android clients reject unknown file tags, so eligible senders and
|
||||
/// receivers derive the receipt key from fields already on the wire.
|
||||
///
|
||||
/// Locally-created image and voice-note filenames contain a UUID or live-voice
|
||||
/// burst ID. Including the normalized direction keeps a reused filename
|
||||
/// distinct across chats while allowing short and full Noise-key peer IDs to
|
||||
/// converge. Android and older-iOS timestamp-only names remain ineligible and
|
||||
/// retain their legacy random local IDs (transfer-compatible, no receipts).
|
||||
enum PrivateMediaMessageIdentity {
|
||||
private static let domain = Data("bitchat-private-media-message-v1".utf8)
|
||||
private static let idPrefix = "media-"
|
||||
private static let digestHexLength = 32
|
||||
|
||||
static func isStableID(_ candidate: String) -> Bool {
|
||||
guard candidate.hasPrefix(idPrefix) else { return false }
|
||||
let digest = candidate.dropFirst(idPrefix.count)
|
||||
guard digest.utf8.count == digestHexLength else { return false }
|
||||
return digest.utf8.allSatisfy { byte in
|
||||
(UInt8(ascii: "0")...UInt8(ascii: "9")).contains(byte)
|
||||
|| (UInt8(ascii: "a")...UInt8(ascii: "f")).contains(byte)
|
||||
}
|
||||
}
|
||||
|
||||
static func stableID(
|
||||
senderPeerID: PeerID,
|
||||
recipientPeerID: PeerID,
|
||||
fileName: String?
|
||||
) -> String? {
|
||||
guard let fileName, !fileName.isEmpty else { return nil }
|
||||
let leafName = (fileName as NSString).lastPathComponent
|
||||
guard leafName == fileName else { return nil }
|
||||
|
||||
let path = leafName as NSString
|
||||
let stem = path.deletingPathExtension
|
||||
let fileExtension = path.pathExtension.lowercased()
|
||||
switch true {
|
||||
case stem.hasPrefix("img_"):
|
||||
guard fileExtension == "jpg" || fileExtension == "jpeg" else { return nil }
|
||||
case stem.hasPrefix("voice_"):
|
||||
guard fileExtension == "m4a" else { return nil }
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
let entropyToken = stem.split(separator: "_").last.map(String.init)
|
||||
let hasUUIDEntropy = entropyToken.flatMap(UUID.init(uuidString:)) != nil
|
||||
let voiceBurstID = stem.hasPrefix("voice_")
|
||||
? String(stem.dropFirst("voice_".count))
|
||||
: ""
|
||||
let hasBurstEntropy = voiceBurstID.count == 16
|
||||
&& voiceBurstID.allSatisfy(\.isHexDigit)
|
||||
guard hasUUIDEntropy || hasBurstEntropy else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let fields = [
|
||||
Data(senderPeerID.toShort().bare.utf8),
|
||||
Data(recipientPeerID.toShort().bare.utf8),
|
||||
Data(leafName.utf8)
|
||||
]
|
||||
var input = domain
|
||||
for field in fields {
|
||||
guard let length = UInt32(exactly: field.count) else { return nil }
|
||||
var bigEndianLength = length.bigEndian
|
||||
withUnsafeBytes(of: &bigEndianLength) {
|
||||
input.append(contentsOf: $0)
|
||||
}
|
||||
input.append(field)
|
||||
}
|
||||
|
||||
return "\(idPrefix)\(input.sha256Hex().prefix(digestHexLength))"
|
||||
}
|
||||
|
||||
static func stableID(
|
||||
for packet: BitchatFilePacket,
|
||||
senderPeerID: PeerID,
|
||||
recipientPeerID: PeerID
|
||||
) -> String? {
|
||||
stableID(
|
||||
senderPeerID: senderPeerID,
|
||||
recipientPeerID: recipientPeerID,
|
||||
fileName: packet.fileName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,5 +3,11 @@ import BitFoundation
|
||||
extension PeerCapabilities {
|
||||
/// Capabilities this build advertises in its announce packets.
|
||||
/// Each feature adds its bit here when it ships.
|
||||
static let localSupported: PeerCapabilities = [.vouch, .prekeys, .groups, .privateMedia]
|
||||
static let localSupported: PeerCapabilities = [
|
||||
.vouch,
|
||||
.prekeys,
|
||||
.groups,
|
||||
.privateMedia,
|
||||
.privateMediaReceipts
|
||||
]
|
||||
}
|
||||
|
||||
@@ -32,10 +32,79 @@ struct BLEFileTransferHandlerEnvironment {
|
||||
_ fallbackExtension: String?,
|
||||
_ defaultPrefix: String
|
||||
) -> URL?
|
||||
/// Resolves the durable receiver decision for a stable private-media ID.
|
||||
let privateMediaReceiptState: (
|
||||
_ messageID: String
|
||||
) -> BLEPrivateMediaReceiptState
|
||||
/// Atomically records a stable private-media ID after the payload save.
|
||||
let commitPrivateMediaFile: (_ messageID: String, _ storedURL: URL) -> Bool
|
||||
/// Rolls back a saved payload when its durable receipt commit fails.
|
||||
let removeIncomingFile: (_ storedURL: URL) -> Void
|
||||
/// Checks the authenticated sender before any private-media disk work.
|
||||
let isPrivateMediaSenderBlocked: (PeerID) -> Bool
|
||||
/// Updates the registry last-seen timestamp for the peer (async barrier write).
|
||||
let updatePeerLastSeen: (PeerID) -> Void
|
||||
/// Delivers `.messageReceived` to the UI as one main-actor hop.
|
||||
let deliverMessage: (BitchatMessage) -> Void
|
||||
/// Acknowledges stable private media only after its synchronous
|
||||
/// conversation delivery has completed.
|
||||
let acknowledgePrivateMedia: (_ messageID: String, _ peerID: PeerID) -> Void
|
||||
/// Delivers `.messageReceived` as one main-actor hop while
|
||||
/// `shouldDeliver` remains true before and after the synchronous sink.
|
||||
/// The completion authorizes the stable-media ACK.
|
||||
let deliverMessage: (
|
||||
_ message: BitchatMessage,
|
||||
_ shouldDeliver: @escaping () -> Bool,
|
||||
_ completion: @escaping () -> Void
|
||||
) -> Void
|
||||
}
|
||||
|
||||
/// Process-lifetime reservation cache for stable private-media IDs.
|
||||
///
|
||||
/// The first arrival reserves its ID before quota enforcement. Concurrent
|
||||
/// arrivals remain coalesced in memory, while accepted state is resolved from
|
||||
/// the durable ID-to-file ledger so it survives relaunch and becomes retryable
|
||||
/// if quota cleanup removed the file.
|
||||
private final class PrivateMediaArrivalDeduplicator {
|
||||
enum Reservation {
|
||||
case reserved
|
||||
case pending
|
||||
case accepted(URL)
|
||||
case tombstoned
|
||||
case unavailable
|
||||
}
|
||||
|
||||
private let lock = NSLock()
|
||||
private var pending: Set<String> = []
|
||||
|
||||
func reserve(
|
||||
_ messageID: String,
|
||||
receiptState: () -> BLEPrivateMediaReceiptState
|
||||
) -> Reservation {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
if pending.contains(messageID) {
|
||||
return .pending
|
||||
}
|
||||
|
||||
switch receiptState() {
|
||||
case .accepted(let existingURL):
|
||||
return .accepted(existingURL)
|
||||
case .tombstoned:
|
||||
return .tombstoned
|
||||
case .unavailable:
|
||||
return .unavailable
|
||||
case .absent:
|
||||
break
|
||||
}
|
||||
|
||||
pending.insert(messageID)
|
||||
return .reserved
|
||||
}
|
||||
|
||||
func finish(_ messageID: String) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
pending.remove(messageID)
|
||||
}
|
||||
}
|
||||
|
||||
/// Orchestrates inbound file transfers: self-echo policy, sender display-name
|
||||
@@ -43,6 +112,7 @@ struct BLEFileTransferHandlerEnvironment {
|
||||
/// and UI delivery.
|
||||
final class BLEFileTransferHandler {
|
||||
private let environment: BLEFileTransferHandlerEnvironment
|
||||
private let privateMediaArrivals = PrivateMediaArrivalDeduplicator()
|
||||
|
||||
init(environment: BLEFileTransferHandlerEnvironment) {
|
||||
self.environment = environment
|
||||
@@ -86,6 +156,7 @@ final class BLEFileTransferHandler {
|
||||
senderNickname: senderNickname,
|
||||
timestamp: Date(timeIntervalSince1970: Double(packet.timestamp) / 1000),
|
||||
isPrivate: deliveryPlan.isPrivateMessage,
|
||||
usesDurableReceipts: false,
|
||||
env: env
|
||||
)
|
||||
// Once authenticated, a local decode/quota/save failure is not proof
|
||||
@@ -116,6 +187,11 @@ final class BLEFileTransferHandler {
|
||||
senderNickname: senderNickname,
|
||||
timestamp: timestamp,
|
||||
isPrivate: true,
|
||||
// Every authenticated Noise private-file keeps the stable ID/ACK
|
||||
// contract introduced with capability bit 8. Bit 9 advertises
|
||||
// sender-side automatic retry support; it must not downgrade
|
||||
// prior iOS clients to random IDs or single-check delivery.
|
||||
usesDurableReceipts: true,
|
||||
env: env
|
||||
)
|
||||
}
|
||||
@@ -126,9 +202,11 @@ final class BLEFileTransferHandler {
|
||||
senderNickname: String,
|
||||
timestamp: Date,
|
||||
isPrivate: Bool,
|
||||
usesDurableReceipts: Bool,
|
||||
env: BLEFileTransferHandlerEnvironment
|
||||
) -> Bool {
|
||||
|
||||
let localPeerID = env.localPeerID()
|
||||
let filePacket: BitchatFilePacket
|
||||
let mime: MimeType
|
||||
switch BLEIncomingFileValidator.validate(payload: payload) {
|
||||
@@ -149,6 +227,89 @@ final class BLEFileTransferHandler {
|
||||
return false
|
||||
}
|
||||
|
||||
if isPrivate, env.isPrivateMediaSenderBlocked(peerID) {
|
||||
SecureLogger.debug(
|
||||
"🚫 Dropping private media from blocked peer \(peerID.id.prefix(8))… before disk write",
|
||||
category: .security
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
let messageID = usesDurableReceipts
|
||||
? PrivateMediaMessageIdentity.stableID(
|
||||
for: filePacket,
|
||||
senderPeerID: peerID,
|
||||
recipientPeerID: localPeerID
|
||||
)
|
||||
: nil
|
||||
if let messageID {
|
||||
switch privateMediaArrivals.reserve(
|
||||
messageID,
|
||||
receiptState: { env.privateMediaReceiptState(messageID) }
|
||||
) {
|
||||
case .reserved:
|
||||
break
|
||||
case .pending:
|
||||
// The first arrival has not reached durable storage yet.
|
||||
// Coalesce this retry without ACKing so a failed first save
|
||||
// remains retryable by the sender.
|
||||
SecureLogger.debug(
|
||||
"📁 Coalesced in-flight private media id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))…",
|
||||
category: .session
|
||||
)
|
||||
return true
|
||||
case .accepted(let existingFile):
|
||||
env.updatePeerLastSeen(peerID)
|
||||
let message = incomingMessage(
|
||||
messageID: messageID,
|
||||
senderNickname: senderNickname,
|
||||
timestamp: timestamp,
|
||||
isPrivate: true,
|
||||
peerID: peerID,
|
||||
destination: existingFile,
|
||||
category: storedMediaCategory(
|
||||
for: existingFile,
|
||||
fallback: mime.category
|
||||
),
|
||||
env: env
|
||||
)
|
||||
SecureLogger.debug(
|
||||
"📁 Restored durable private media duplicate id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))… -> \(existingFile.lastPathComponent)",
|
||||
category: .session
|
||||
)
|
||||
deliverStableMessage(
|
||||
message,
|
||||
messageID: messageID,
|
||||
peerID: peerID,
|
||||
expectedURL: existingFile,
|
||||
env: env
|
||||
)
|
||||
return true
|
||||
case .tombstoned:
|
||||
// Explicit deletion is a durable terminal receiver decision.
|
||||
env.updatePeerLastSeen(peerID)
|
||||
env.acknowledgePrivateMedia(messageID, peerID)
|
||||
SecureLogger.debug(
|
||||
"📁 Dropped explicitly deleted private media id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))…",
|
||||
category: .session
|
||||
)
|
||||
return true
|
||||
case .unavailable:
|
||||
// Never turn an unreadable ledger into an empty ledger. The
|
||||
// sender can retry after the transient storage failure clears.
|
||||
SecureLogger.warning(
|
||||
"📁 Withholding private media id=\(messageID.prefix(12))… while durable receipt state is unavailable",
|
||||
category: .session
|
||||
)
|
||||
return true
|
||||
}
|
||||
}
|
||||
defer {
|
||||
if let messageID {
|
||||
privateMediaArrivals.finish(messageID)
|
||||
}
|
||||
}
|
||||
|
||||
// BCH-01-002: Enforce storage quota before saving
|
||||
env.enforceStorageQuota(filePacket.content.count)
|
||||
|
||||
@@ -162,13 +323,82 @@ final class BLEFileTransferHandler {
|
||||
return false
|
||||
}
|
||||
|
||||
if let messageID,
|
||||
!env.commitPrivateMediaFile(messageID, destination) {
|
||||
// A payload without its durable ID mapping cannot safely suppress
|
||||
// a retry after relaunch. Roll it back and withhold UI/ACK.
|
||||
env.removeIncomingFile(destination)
|
||||
return false
|
||||
}
|
||||
|
||||
if isPrivate {
|
||||
env.updatePeerLastSeen(peerID)
|
||||
}
|
||||
|
||||
let message = BitchatMessage(
|
||||
let message = incomingMessage(
|
||||
messageID: messageID,
|
||||
senderNickname: senderNickname,
|
||||
timestamp: timestamp,
|
||||
isPrivate: isPrivate,
|
||||
peerID: peerID,
|
||||
destination: destination,
|
||||
category: mime.category,
|
||||
env: env
|
||||
)
|
||||
|
||||
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
|
||||
|
||||
if let messageID {
|
||||
deliverStableMessage(
|
||||
message,
|
||||
messageID: messageID,
|
||||
peerID: peerID,
|
||||
expectedURL: destination,
|
||||
env: env
|
||||
)
|
||||
} else {
|
||||
env.deliverMessage(message, { true }, {})
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func deliverStableMessage(
|
||||
_ message: BitchatMessage,
|
||||
messageID: String,
|
||||
peerID: PeerID,
|
||||
expectedURL: URL,
|
||||
env: BLEFileTransferHandlerEnvironment
|
||||
) {
|
||||
env.deliverMessage(
|
||||
message,
|
||||
{
|
||||
guard case .accepted(let resolvedURL) =
|
||||
env.privateMediaReceiptState(messageID) else {
|
||||
return false
|
||||
}
|
||||
return resolvedURL.standardizedFileURL
|
||||
== expectedURL.standardizedFileURL
|
||||
},
|
||||
{
|
||||
env.acknowledgePrivateMedia(messageID, peerID)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func incomingMessage(
|
||||
messageID: String?,
|
||||
senderNickname: String,
|
||||
timestamp: Date,
|
||||
isPrivate: Bool,
|
||||
peerID: PeerID,
|
||||
destination: URL,
|
||||
category: MimeType.Category,
|
||||
env: BLEFileTransferHandlerEnvironment
|
||||
) -> BitchatMessage {
|
||||
BitchatMessage(
|
||||
id: messageID,
|
||||
sender: senderNickname,
|
||||
content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)",
|
||||
content: "\(category.messagePrefix)\(destination.lastPathComponent)",
|
||||
timestamp: timestamp,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
@@ -176,17 +406,35 @@ final class BLEFileTransferHandler {
|
||||
recipientNickname: nil,
|
||||
senderPeerID: peerID,
|
||||
// Received messages need an explicit status: BitchatMessage
|
||||
// defaults private messages to .sending, which the media views
|
||||
// render as an in-flight send (empty reveal mask, disabled tap).
|
||||
// defaults private messages to .sending, which media views render
|
||||
// as an in-flight send.
|
||||
deliveryStatus: isPrivate
|
||||
? .delivered(to: env.localNickname(), at: timestamp)
|
||||
: nil
|
||||
)
|
||||
}
|
||||
|
||||
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
|
||||
|
||||
env.deliverMessage(message)
|
||||
return true
|
||||
/// The durable URL is authoritative during reconstruction. A sender that
|
||||
/// reuses a stable filename with a different MIME type must not change how
|
||||
/// the already-stored payload renders.
|
||||
private func storedMediaCategory(
|
||||
for url: URL,
|
||||
fallback: MimeType.Category
|
||||
) -> MimeType.Category {
|
||||
let mediaDirectory = url
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.lastPathComponent
|
||||
switch mediaDirectory {
|
||||
case MimeType.Category.audio.mediaDir:
|
||||
return .audio
|
||||
case MimeType.Category.image.mediaDir:
|
||||
return .image
|
||||
case MimeType.Category.file.mediaDir:
|
||||
return .file
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
/// Every remaining raw file transfer is signed, regardless of whether it
|
||||
|
||||
@@ -134,6 +134,7 @@ struct BLEIncomingFileStore {
|
||||
private let baseDirectory: URL?
|
||||
private let dateProvider: () -> Date
|
||||
private let panicMarkerWriter: (Data, URL) throws -> Void
|
||||
private let privateMediaReceipts: BLEPrivateMediaReceiptStore
|
||||
|
||||
init(
|
||||
fileManager: FileManager = .default,
|
||||
@@ -147,6 +148,11 @@ struct BLEIncomingFileStore {
|
||||
self.baseDirectory = baseDirectory
|
||||
self.dateProvider = dateProvider
|
||||
self.panicMarkerWriter = panicMarkerWriter
|
||||
self.privateMediaReceipts = BLEPrivateMediaReceiptStore(
|
||||
fileManager: fileManager,
|
||||
baseDirectory: baseDirectory,
|
||||
now: dateProvider
|
||||
)
|
||||
}
|
||||
|
||||
/// Panic-wipe every managed incoming and outgoing media artifact before
|
||||
@@ -159,6 +165,11 @@ struct BLEIncomingFileStore {
|
||||
func panicWipe(
|
||||
hasDurablePendingMarker: Bool = false
|
||||
) throws {
|
||||
// The receipt index caches tombstones as well as accepted payloads.
|
||||
// Always invalidate it on return, including partial-failure paths, so
|
||||
// no pre-panic receiver decision survives after identity reset.
|
||||
defer { privateMediaReceipts.resetForPanic() }
|
||||
|
||||
let markerError: Error?
|
||||
do {
|
||||
try markPanicRecoveryPending()
|
||||
@@ -257,6 +268,35 @@ struct BLEIncomingFileStore {
|
||||
}
|
||||
}
|
||||
|
||||
func privateMediaReceiptState(
|
||||
messageID: String
|
||||
) -> BLEPrivateMediaReceiptState {
|
||||
privateMediaReceipts.state(for: messageID)
|
||||
}
|
||||
|
||||
func commitPrivateMediaFile(
|
||||
messageID: String,
|
||||
storedURL: URL
|
||||
) -> Bool {
|
||||
privateMediaReceipts.commitAccepted(
|
||||
messageID: messageID,
|
||||
storedURL: storedURL
|
||||
)
|
||||
}
|
||||
|
||||
/// Best-effort rollback for a payload whose durable receipt commit failed.
|
||||
func removeIncomingFile(at storedURL: URL) {
|
||||
guard isURLInsideFilesDirectory(storedURL) else { return }
|
||||
do {
|
||||
try fileManager.removeItem(at: storedURL)
|
||||
} catch {
|
||||
SecureLogger.warning(
|
||||
"⚠️ Failed to roll back uncommitted incoming media: \(error)",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Frees least-recently-modified incoming files until `reservingBytes`
|
||||
/// fits under the quota. Files named `voice_live_*` (in-flight live
|
||||
/// captures) are never evicted regardless of who triggers enforcement —
|
||||
@@ -349,6 +389,13 @@ struct BLEIncomingFileStore {
|
||||
]
|
||||
}
|
||||
|
||||
private func isURLInsideFilesDirectory(_ url: URL) -> Bool {
|
||||
guard let filesDirectory = try? filesDirectory().standardizedFileURL else {
|
||||
return false
|
||||
}
|
||||
return url.standardizedFileURL.path.hasPrefix(filesDirectory.path + "/")
|
||||
}
|
||||
|
||||
private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
|
||||
var candidate = (name ?? "")
|
||||
.replacingOccurrences(of: "\0", with: "")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import BitFoundation
|
||||
import BitLogger
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
struct BLENoiseHandshakeHandlingResult {
|
||||
@@ -33,6 +34,8 @@ struct BLENoisePacketHandlerEnvironment {
|
||||
-> NoiseHandshakeProcessingResult
|
||||
/// Whether any Noise session (established or pending) exists for the peer (crypto).
|
||||
let hasNoiseSession: (PeerID) -> Bool
|
||||
/// Whether an inbound ordinary XX responder is waiting for message 3.
|
||||
let isAwaitingResponderHandshakeCompletion: (PeerID) -> Bool
|
||||
/// Initiates a fresh Noise handshake with the peer (crypto + send).
|
||||
let initiateHandshake: (PeerID) -> Void
|
||||
/// Broadcasts a packet on the mesh (caller is already on the message queue).
|
||||
@@ -63,15 +66,36 @@ struct BLENoisePacketHandlerEnvironment {
|
||||
/// processing (with response), encrypted payload decryption and dispatch,
|
||||
/// and session recovery on decrypt failure.
|
||||
final class BLENoisePacketHandler {
|
||||
private struct DeferredCiphertext {
|
||||
let packet: BitchatPacket
|
||||
let receivedAt: Date
|
||||
}
|
||||
|
||||
/// Early post-handshake packets are normally tiny control messages or
|
||||
/// queued DMs. Keep the recovery surface deliberately small so an
|
||||
/// unauthenticated half-handshake cannot create an unbounded memory queue.
|
||||
private static let maxDeferredPacketsPerPeer = 4
|
||||
private static let maxDeferredPacketsGlobal = 32
|
||||
/// One legacy sender can immediately follow message 3 with the largest
|
||||
/// valid private-file ciphertext and has no application-level retry. Keep
|
||||
/// room for that packet plus a small control-message budget.
|
||||
private static let maxDeferredBytes =
|
||||
NoiseSecurityConstants.maxPrivateFileCiphertextSize + 256 * 1024
|
||||
private static let deferredLifetime =
|
||||
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
|
||||
|
||||
private let environment: BLENoisePacketHandlerEnvironment
|
||||
private let deferredLock = NSLock()
|
||||
private var deferredCiphertexts: [PeerID: [DeferredCiphertext]] = [:]
|
||||
private var deferredCiphertextBytes = 0
|
||||
|
||||
init(environment: BLENoisePacketHandlerEnvironment) {
|
||||
self.environment = environment
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
handleHandshakeWithResult(packet, from: peerID).processed
|
||||
@@ -105,15 +129,23 @@ final class BLENoisePacketHandler {
|
||||
env.broadcastPacket(responsePacket)
|
||||
}
|
||||
|
||||
// Session establishment will trigger onPeerAuthenticated callback
|
||||
// which will send any pending messages at the right time
|
||||
// The serialized authentication callback installs transport
|
||||
// state before it drains any bounded early ciphertext.
|
||||
return BLENoiseHandshakeHandlingResult(
|
||||
processed: true,
|
||||
didEstablishAuthenticatedSession:
|
||||
result.didEstablishAuthenticatedSession
|
||||
)
|
||||
} catch let managedFailure as NoiseManagedHandshakeFailure {
|
||||
SecureLogger.error(
|
||||
"Failed to process handshake; manager owns recovery: \(managedFailure.underlying)"
|
||||
)
|
||||
return BLENoiseHandshakeHandlingResult(
|
||||
processed: false,
|
||||
didEstablishAuthenticatedSession: false
|
||||
)
|
||||
} 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(
|
||||
@@ -143,6 +175,30 @@ final class BLENoisePacketHandler {
|
||||
}
|
||||
|
||||
func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
handleEncrypted(packet, from: peerID, isDeferredRetry: false)
|
||||
}
|
||||
|
||||
/// Called by the transport's serialized authentication callback after it
|
||||
/// has installed state for the promoted or restored session generation.
|
||||
func handleSessionAuthenticated(_ peerID: PeerID) {
|
||||
drainDeferredCiphertextsIfReady(for: peerID)
|
||||
}
|
||||
|
||||
/// Synchronously discards ciphertext retained for a pre-panic Noise
|
||||
/// generation. The handler survives the service's identity replacement,
|
||||
/// so keeping this queue would replay old bytes after post-panic auth.
|
||||
func resetForPanic() {
|
||||
deferredLock.lock()
|
||||
deferredCiphertexts.removeAll(keepingCapacity: false)
|
||||
deferredCiphertextBytes = 0
|
||||
deferredLock.unlock()
|
||||
}
|
||||
|
||||
private func handleEncrypted(
|
||||
_ packet: BitchatPacket,
|
||||
from peerID: PeerID,
|
||||
isDeferredRetry: Bool
|
||||
) {
|
||||
let env = environment
|
||||
guard let recipientID = PeerID(hexData: packet.recipientID) else {
|
||||
SecureLogger.warning("⚠️ Encrypted message has no recipient ID", category: .session)
|
||||
@@ -184,19 +240,205 @@ final class BLENoisePacketHandler {
|
||||
|
||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||
env.deliverNoisePayload(peerID, noisePayloadType, Data(payloadData), ts)
|
||||
} catch NoiseEncryptionError.transportGenerationNotReady {
|
||||
if isDeferredRetry {
|
||||
SecureLogger.warning(
|
||||
"Dropping deferred Noise ciphertext from \(peerID.id.prefix(8))… because its authenticated transport generation changed again",
|
||||
category: .session
|
||||
)
|
||||
return
|
||||
}
|
||||
// The manager promoted or restored keys before BLE's serialized
|
||||
// callback installed generation-bound transport state. The
|
||||
// manager rejected this before decrypting, so replay is safe.
|
||||
deferCiphertext(packet, from: peerID)
|
||||
} catch NoiseEncryptionError.sessionNotEstablished {
|
||||
if isDeferredRetry {
|
||||
SecureLogger.warning(
|
||||
"Dropping deferred Noise ciphertext from \(peerID.id.prefix(8))… because the authenticated session is unavailable",
|
||||
category: .session
|
||||
)
|
||||
return
|
||||
}
|
||||
// We received an encrypted message before establishing a session with this peer.
|
||||
// Trigger a handshake so future messages can be decrypted.
|
||||
// An initiator may already have sent message 3 followed by this
|
||||
// ciphertext, with BLE delivering the ciphertext first.
|
||||
if env.isAwaitingResponderHandshakeCompletion(peerID) {
|
||||
deferCiphertext(packet, from: peerID)
|
||||
return
|
||||
}
|
||||
// Otherwise trigger a handshake so future messages can decrypt.
|
||||
SecureLogger.debug("🔑 Encrypted message from \(peerID.id.prefix(8))… without session; initiating handshake")
|
||||
if !env.hasNoiseSession(peerID) {
|
||||
env.initiateHandshake(peerID)
|
||||
}
|
||||
} catch {
|
||||
if isDeferredRetry {
|
||||
// An early packet cannot tear down the authenticated session
|
||||
// merely because its single bounded retry still fails.
|
||||
SecureLogger.warning(
|
||||
"Dropping deferred Noise ciphertext from \(peerID.id.prefix(8))… after retry failed: \(error)",
|
||||
category: .session
|
||||
)
|
||||
return
|
||||
}
|
||||
// A responder may retain an older transport as receive-only
|
||||
// rollback state while ordinary XX waits for message 3. New-key
|
||||
// ciphertext can fail against those retained receive keys first.
|
||||
if env.isAwaitingResponderHandshakeCompletion(peerID) {
|
||||
if isDeferrableEarlyHandshakeFailure(error) {
|
||||
deferCiphertext(packet, from: peerID)
|
||||
} else {
|
||||
SecureLogger.warning(
|
||||
"Dropping invalid Noise ciphertext from \(peerID.id.prefix(8))… while responder handshake is completing: \(error)",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
if isDropOnlyCiphertextFailure(error) {
|
||||
// The packet is attacker-controlled and did not prove a
|
||||
// transport-state failure. Never let malformed, replayed,
|
||||
// forged, oversized, or rate-limited bytes evict working keys.
|
||||
SecureLogger.warning(
|
||||
"Dropping rejected Noise ciphertext from \(peerID.id.prefix(8))… without clearing its session: \(error)",
|
||||
category: .security
|
||||
)
|
||||
return
|
||||
}
|
||||
// Decryption failed - clear the corrupted session and re-initiate handshake
|
||||
// This handles cases where session state got out of sync (nonce mismatch, etc.)
|
||||
// Only local/session lifecycle failures reach this path.
|
||||
SecureLogger.error("❌ Failed to decrypt message from \(peerID.id.prefix(8))…: \(error) - clearing session and re-initiating handshake")
|
||||
env.clearSession(peerID)
|
||||
env.initiateHandshake(peerID)
|
||||
}
|
||||
}
|
||||
|
||||
private func isDeferrableEarlyHandshakeFailure(_ error: Error) -> Bool {
|
||||
if let noiseError = error as? NoiseError {
|
||||
switch noiseError {
|
||||
case .authenticationFailure, .replayDetected:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
if let cryptoError = error as? CryptoKitError,
|
||||
case .authenticationFailure = cryptoError {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func isDropOnlyCiphertextFailure(_ error: Error) -> Bool {
|
||||
if let securityError = error as? NoiseSecurityError {
|
||||
switch securityError {
|
||||
case .messageTooLarge, .rateLimitExceeded, .invalidPeerID:
|
||||
return true
|
||||
case .sessionExpired, .sessionExhausted:
|
||||
return false
|
||||
}
|
||||
}
|
||||
if let noiseError = error as? NoiseError {
|
||||
switch noiseError {
|
||||
case .invalidCiphertext, .authenticationFailure, .replayDetected:
|
||||
return true
|
||||
case .uninitializedCipher, .handshakeComplete,
|
||||
.handshakeNotComplete, .missingLocalStaticKey,
|
||||
.missingKeys, .invalidMessage, .invalidPublicKey,
|
||||
.nonceExceeded:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return error is CryptoKitError
|
||||
}
|
||||
|
||||
private func deferCiphertext(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
guard NoiseSecurityValidator.validatePrivateFileCiphertextSize(
|
||||
packet.payload
|
||||
) else {
|
||||
SecureLogger.warning(
|
||||
"Dropping oversized early Noise ciphertext from \(peerID.id.prefix(8))…",
|
||||
category: .security
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let now = environment.now()
|
||||
deferredLock.lock()
|
||||
defer { deferredLock.unlock() }
|
||||
purgeExpiredCiphertextsLocked(now: now)
|
||||
|
||||
let peerCount = deferredCiphertexts[peerID]?.count ?? 0
|
||||
let globalCount = deferredCiphertexts.values.reduce(0) {
|
||||
$0 + $1.count
|
||||
}
|
||||
guard peerCount < Self.maxDeferredPacketsPerPeer,
|
||||
globalCount < Self.maxDeferredPacketsGlobal,
|
||||
deferredCiphertextBytes + packet.payload.count
|
||||
<= Self.maxDeferredBytes else {
|
||||
SecureLogger.warning(
|
||||
"Dropping early Noise ciphertext from \(peerID.id.prefix(8))… because the handshake buffer is full",
|
||||
category: .security
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
deferredCiphertexts[peerID, default: []].append(
|
||||
DeferredCiphertext(packet: packet, receivedAt: now)
|
||||
)
|
||||
deferredCiphertextBytes += packet.payload.count
|
||||
SecureLogger.debug(
|
||||
"Deferring early Noise ciphertext from \(peerID.id.prefix(8))… until responder handshake completion",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
|
||||
private func drainDeferredCiphertextsIfReady(for peerID: PeerID) {
|
||||
let env = environment
|
||||
guard !env.isAwaitingResponderHandshakeCompletion(peerID),
|
||||
env.hasNoiseSession(peerID) else {
|
||||
return
|
||||
}
|
||||
|
||||
let now = env.now()
|
||||
deferredLock.lock()
|
||||
purgeExpiredCiphertextsLocked(now: now)
|
||||
let deferred = deferredCiphertexts.removeValue(forKey: peerID) ?? []
|
||||
deferredCiphertextBytes -= deferred.reduce(0) {
|
||||
$0 + $1.packet.payload.count
|
||||
}
|
||||
deferredLock.unlock()
|
||||
|
||||
guard !deferred.isEmpty else { return }
|
||||
SecureLogger.debug(
|
||||
"Retrying \(deferred.count) early Noise ciphertext packet(s) from \(peerID.id.prefix(8))… after handshake completion",
|
||||
category: .session
|
||||
)
|
||||
for item in deferred {
|
||||
handleEncrypted(item.packet, from: peerID, isDeferredRetry: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func purgeExpiredCiphertextsLocked(now: Date) {
|
||||
for peerID in Array(deferredCiphertexts.keys) {
|
||||
guard let items = deferredCiphertexts[peerID] else { continue }
|
||||
let retained = items.filter {
|
||||
now.timeIntervalSince($0.receivedAt) <= Self.deferredLifetime
|
||||
}
|
||||
guard retained.count != items.count else { continue }
|
||||
|
||||
deferredCiphertextBytes -= items.reduce(0) {
|
||||
$0 + $1.packet.payload.count
|
||||
}
|
||||
deferredCiphertextBytes += retained.reduce(0) {
|
||||
$0 + $1.packet.payload.count
|
||||
}
|
||||
if retained.isEmpty {
|
||||
deferredCiphertexts.removeValue(forKey: peerID)
|
||||
} else {
|
||||
deferredCiphertexts[peerID] = retained
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
enum BLEPrivateMediaReceiptState: Equatable {
|
||||
/// No durable receiver decision exists for this stable message ID.
|
||||
case absent
|
||||
/// The payload is durably mapped to a file that still exists.
|
||||
case accepted(URL)
|
||||
/// The user explicitly deleted the payload; retries must not resurrect it.
|
||||
case tombstoned
|
||||
/// Durable state could not be read safely. Callers must fail closed and
|
||||
/// must not save, deliver, or acknowledge the payload.
|
||||
case unavailable
|
||||
}
|
||||
|
||||
/// Durable, per-message receiver decisions for stable private media.
|
||||
///
|
||||
/// Each ID has its own atomic record so one hot lookup never rewrites or
|
||||
/// decodes the entire ledger. The process-lifetime index is installed only
|
||||
/// after a complete, successful directory scan. An enumeration, read, decode,
|
||||
/// or structural-validation failure therefore remains retryable and cannot be
|
||||
/// mistaken for an empty ledger.
|
||||
final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||
typealias DirectoryReader = (_ directory: URL) throws -> [URL]
|
||||
typealias DataReader = (_ url: URL) throws -> Data
|
||||
private static let receiptDirectoryName = ".private-media-receipts"
|
||||
|
||||
private struct ReceiptRecord: Codable, Equatable {
|
||||
enum Kind: String, Codable {
|
||||
case accepted
|
||||
case tombstone
|
||||
}
|
||||
|
||||
let kind: Kind
|
||||
/// Path below the app's `files/` root. Absolute application-container
|
||||
/// prefixes are not stable across updates, restores, or reinstalls.
|
||||
let relativePath: String?
|
||||
let recordedAt: Date
|
||||
}
|
||||
|
||||
private final class Runtime: @unchecked Sendable {
|
||||
let lock = NSLock()
|
||||
var records: [String: ReceiptRecord]?
|
||||
var volatileTombstones: [String: Date] = [:]
|
||||
}
|
||||
|
||||
private let fileManager: FileManager
|
||||
private let baseDirectory: URL?
|
||||
private let capacity: Int
|
||||
private let ttl: TimeInterval
|
||||
private let now: () -> Date
|
||||
private let directoryReader: DirectoryReader?
|
||||
private let dataReader: DataReader?
|
||||
private let runtime = Runtime()
|
||||
|
||||
init(
|
||||
fileManager: FileManager = .default,
|
||||
baseDirectory: URL? = nil,
|
||||
capacity: Int = TransportConfig.privateMediaReceivedLedgerCapacity,
|
||||
ttl: TimeInterval = TransportConfig.privateMediaReceivedLedgerTTLSeconds,
|
||||
now: @escaping () -> Date = Date.init,
|
||||
directoryReader: DirectoryReader? = nil,
|
||||
dataReader: DataReader? = nil
|
||||
) {
|
||||
self.fileManager = fileManager
|
||||
self.baseDirectory = baseDirectory
|
||||
self.capacity = max(1, capacity)
|
||||
self.ttl = max(0, ttl)
|
||||
self.now = now
|
||||
self.directoryReader = directoryReader
|
||||
self.dataReader = dataReader
|
||||
}
|
||||
|
||||
/// Drops process-lifetime decisions after the enclosing media directory
|
||||
/// has been panic-wiped. A later lookup must rebuild from the durable
|
||||
/// ledger instead of retaining an accepted receipt or tombstone whose
|
||||
/// backing files no longer exist.
|
||||
func resetForPanic() {
|
||||
runtime.lock.lock()
|
||||
runtime.records = nil
|
||||
runtime.volatileTombstones.removeAll(keepingCapacity: false)
|
||||
runtime.lock.unlock()
|
||||
}
|
||||
|
||||
func state(for messageID: String) -> BLEPrivateMediaReceiptState {
|
||||
guard PrivateMediaMessageIdentity.isStableID(messageID) else {
|
||||
return .absent
|
||||
}
|
||||
|
||||
runtime.lock.lock()
|
||||
defer { runtime.lock.unlock() }
|
||||
|
||||
let date = now()
|
||||
if let tombstonedAt = runtime.volatileTombstones[messageID] {
|
||||
if !isExpired(tombstonedAt, at: date) {
|
||||
return .tombstoned
|
||||
}
|
||||
runtime.volatileTombstones.removeValue(forKey: messageID)
|
||||
}
|
||||
|
||||
guard let directory = resolvedReceiptDirectory(),
|
||||
var records = loadIndexIfNeeded(from: directory, at: date) else {
|
||||
return .unavailable
|
||||
}
|
||||
guard let record = records[messageID] else { return .absent }
|
||||
|
||||
if isExpired(record.recordedAt, at: date) {
|
||||
records.removeValue(forKey: messageID)
|
||||
runtime.records = records
|
||||
removeRecord(messageID: messageID, from: directory)
|
||||
return .absent
|
||||
}
|
||||
|
||||
switch record.kind {
|
||||
case .tombstone:
|
||||
removePayloadRecordedByTombstone(record)
|
||||
return .tombstoned
|
||||
|
||||
case .accepted:
|
||||
guard let relativePath = record.relativePath,
|
||||
let existingURL = existingPayload(relativePath: relativePath) else {
|
||||
// Quota cleanup is not explicit deletion. Remove the stale
|
||||
// receipt so a sender retry can restore the payload and bubble.
|
||||
records.removeValue(forKey: messageID)
|
||||
runtime.records = records
|
||||
removeRecord(messageID: messageID, from: directory)
|
||||
return .absent
|
||||
}
|
||||
return .accepted(existingURL)
|
||||
}
|
||||
}
|
||||
|
||||
/// Records an accepted ID only after the payload is on disk. Callers must
|
||||
/// roll the payload back and withhold UI delivery/ACK when this returns
|
||||
/// false.
|
||||
func commitAccepted(messageID: String, storedURL: URL) -> Bool {
|
||||
guard PrivateMediaMessageIdentity.isStableID(messageID),
|
||||
validExistingPayload(storedURL) != nil,
|
||||
let relativePath = relativePath(for: storedURL) else {
|
||||
return false
|
||||
}
|
||||
|
||||
runtime.lock.lock()
|
||||
defer { runtime.lock.unlock() }
|
||||
|
||||
let date = now()
|
||||
if let tombstonedAt = runtime.volatileTombstones[messageID],
|
||||
!isExpired(tombstonedAt, at: date) {
|
||||
return false
|
||||
}
|
||||
|
||||
guard let directory = resolvedReceiptDirectory(),
|
||||
var records = loadIndexIfNeeded(from: directory, at: date) else {
|
||||
return false
|
||||
}
|
||||
if let existing = records[messageID],
|
||||
existing.kind == .tombstone,
|
||||
!isExpired(existing.recordedAt, at: date) {
|
||||
return false
|
||||
}
|
||||
|
||||
let victim = capacityVictim(
|
||||
for: .accepted,
|
||||
replacing: messageID,
|
||||
in: records
|
||||
)
|
||||
if records[messageID]?.kind != .accepted,
|
||||
records.values.lazy.filter({ $0.kind == .accepted }).count >= capacity,
|
||||
victim == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
let record = ReceiptRecord(
|
||||
kind: .accepted,
|
||||
relativePath: relativePath,
|
||||
recordedAt: date
|
||||
)
|
||||
guard persist(record, messageID: messageID, to: directory) else {
|
||||
return false
|
||||
}
|
||||
|
||||
records[messageID] = record
|
||||
if let victim, victim != messageID {
|
||||
records.removeValue(forKey: victim)
|
||||
removeRecord(messageID: victim, from: directory)
|
||||
}
|
||||
runtime.records = records
|
||||
return true
|
||||
}
|
||||
|
||||
/// Foundation for explicit media deletion. This branch does not wire the
|
||||
/// chat-clear UI; it only makes a tombstone durable and fail closed.
|
||||
func recordDeleted(messageID: String) -> Bool {
|
||||
guard PrivateMediaMessageIdentity.isStableID(messageID) else {
|
||||
return false
|
||||
}
|
||||
|
||||
runtime.lock.lock()
|
||||
defer { runtime.lock.unlock() }
|
||||
|
||||
let date = now()
|
||||
addVolatileTombstone(messageID, at: date)
|
||||
|
||||
guard let directory = resolvedReceiptDirectory(),
|
||||
var records = loadIndexIfNeeded(from: directory, at: date) else {
|
||||
runtime.volatileTombstones.removeValue(forKey: messageID)
|
||||
return false
|
||||
}
|
||||
if let existing = records[messageID],
|
||||
existing.kind == .tombstone,
|
||||
!isExpired(existing.recordedAt, at: date) {
|
||||
runtime.volatileTombstones.removeValue(forKey: messageID)
|
||||
removePayloadRecordedByTombstone(existing)
|
||||
return true
|
||||
}
|
||||
|
||||
let victim = capacityVictim(
|
||||
for: .tombstone,
|
||||
replacing: messageID,
|
||||
in: records
|
||||
)
|
||||
if records[messageID]?.kind != .tombstone,
|
||||
records.values.lazy.filter({ $0.kind == .tombstone }).count >= capacity,
|
||||
victim == nil {
|
||||
runtime.volatileTombstones.removeValue(forKey: messageID)
|
||||
return false
|
||||
}
|
||||
|
||||
let tombstone = ReceiptRecord(
|
||||
kind: .tombstone,
|
||||
// Retain the accepted path so a crash between the atomic record
|
||||
// write and payload unlink can finish cleanup after relaunch.
|
||||
relativePath: records[messageID]?.relativePath,
|
||||
recordedAt: date
|
||||
)
|
||||
guard persist(tombstone, messageID: messageID, to: directory) else {
|
||||
runtime.volatileTombstones.removeValue(forKey: messageID)
|
||||
return false
|
||||
}
|
||||
|
||||
records[messageID] = tombstone
|
||||
if let victim, victim != messageID {
|
||||
records.removeValue(forKey: victim)
|
||||
removeRecord(messageID: victim, from: directory)
|
||||
}
|
||||
runtime.records = records
|
||||
runtime.volatileTombstones.removeValue(forKey: messageID)
|
||||
removePayloadRecordedByTombstone(tombstone)
|
||||
return true
|
||||
}
|
||||
|
||||
private func loadIndexIfNeeded(
|
||||
from directory: URL,
|
||||
at date: Date
|
||||
) -> [String: ReceiptRecord]? {
|
||||
if let records = runtime.records {
|
||||
return records
|
||||
}
|
||||
|
||||
do {
|
||||
try fileManager.createDirectory(
|
||||
at: directory,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: nil
|
||||
)
|
||||
} catch {
|
||||
SecureLogger.error(
|
||||
"❌ Failed to create private-media receipt directory: \(error)",
|
||||
category: .session
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
let urls: [URL]
|
||||
do {
|
||||
if let directoryReader {
|
||||
urls = try directoryReader(directory)
|
||||
} else {
|
||||
urls = try fileManager.contentsOfDirectory(
|
||||
at: directory,
|
||||
includingPropertiesForKeys: nil,
|
||||
options: []
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error(
|
||||
"❌ Failed to enumerate private-media receipts: \(error)",
|
||||
category: .session
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
var records: [String: ReceiptRecord] = [:]
|
||||
var expired: [String] = []
|
||||
var tombstones: [ReceiptRecord] = []
|
||||
for url in urls {
|
||||
guard url.pathExtension == "json" else { continue }
|
||||
let messageID = url.deletingPathExtension().lastPathComponent
|
||||
guard PrivateMediaMessageIdentity.isStableID(messageID) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let record: ReceiptRecord
|
||||
do {
|
||||
let data = try dataReader?(url) ?? Data(contentsOf: url)
|
||||
record = try JSONDecoder().decode(ReceiptRecord.self, from: data)
|
||||
} catch {
|
||||
// Never delete or skip an unreadable stable-ID record. Treating
|
||||
// it as absent could resurrect accepted or deleted media.
|
||||
SecureLogger.error(
|
||||
"❌ Failed to read private-media receipt \(messageID.prefix(12))…: \(error)",
|
||||
category: .session
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
guard isStructurallyValid(record) else {
|
||||
SecureLogger.error(
|
||||
"❌ Invalid private-media receipt \(messageID.prefix(12))…",
|
||||
category: .session
|
||||
)
|
||||
return nil
|
||||
}
|
||||
if isExpired(record.recordedAt, at: date) {
|
||||
expired.append(messageID)
|
||||
continue
|
||||
}
|
||||
records[messageID] = record
|
||||
if record.kind == .tombstone {
|
||||
tombstones.append(record)
|
||||
}
|
||||
}
|
||||
|
||||
let overflow = overflowVictims(in: records)
|
||||
for messageID in overflow {
|
||||
records.removeValue(forKey: messageID)
|
||||
}
|
||||
|
||||
// Install the index only after every stable-ID record was read and
|
||||
// validated successfully. Cleanup cannot influence a failed scan.
|
||||
runtime.records = records
|
||||
|
||||
for messageID in expired + overflow {
|
||||
removeRecord(messageID: messageID, from: directory)
|
||||
}
|
||||
for tombstone in tombstones {
|
||||
removePayloadRecordedByTombstone(tombstone)
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
private func isStructurallyValid(_ record: ReceiptRecord) -> Bool {
|
||||
switch record.kind {
|
||||
case .tombstone:
|
||||
guard let relativePath = record.relativePath else { return true }
|
||||
return candidatePayload(relativePath: relativePath) != nil
|
||||
case .accepted:
|
||||
guard let relativePath = record.relativePath else { return false }
|
||||
return candidatePayload(relativePath: relativePath) != nil
|
||||
}
|
||||
}
|
||||
|
||||
private func isExpired(_ recordedAt: Date, at date: Date) -> Bool {
|
||||
date.timeIntervalSince(recordedAt) > ttl
|
||||
}
|
||||
|
||||
private func overflowVictims(
|
||||
in records: [String: ReceiptRecord]
|
||||
) -> [String] {
|
||||
var victims: [String] = []
|
||||
for kind in [ReceiptRecord.Kind.accepted, .tombstone] {
|
||||
let matching = records.filter { $0.value.kind == kind }
|
||||
let overflow = matching.count - capacity
|
||||
guard overflow > 0 else { continue }
|
||||
victims.append(contentsOf: matching.sorted { lhs, rhs in
|
||||
if lhs.value.recordedAt == rhs.value.recordedAt {
|
||||
return lhs.key < rhs.key
|
||||
}
|
||||
return lhs.value.recordedAt < rhs.value.recordedAt
|
||||
}
|
||||
.prefix(overflow)
|
||||
.map(\.key))
|
||||
}
|
||||
return victims
|
||||
}
|
||||
|
||||
/// Accepted receipts and tombstones have independent capacity. High media
|
||||
/// volume cannot evict explicit deletion intent, and vice versa.
|
||||
private func capacityVictim(
|
||||
for incomingKind: ReceiptRecord.Kind,
|
||||
replacing messageID: String,
|
||||
in records: [String: ReceiptRecord]
|
||||
) -> String? {
|
||||
guard records[messageID]?.kind != incomingKind else { return nil }
|
||||
let matching = records.filter {
|
||||
$0.key != messageID && $0.value.kind == incomingKind
|
||||
}
|
||||
guard matching.count >= capacity else { return nil }
|
||||
return matching.min { lhs, rhs in
|
||||
if lhs.value.recordedAt == rhs.value.recordedAt {
|
||||
return lhs.key < rhs.key
|
||||
}
|
||||
return lhs.value.recordedAt < rhs.value.recordedAt
|
||||
}?.key
|
||||
}
|
||||
|
||||
private func persist(
|
||||
_ record: ReceiptRecord,
|
||||
messageID: String,
|
||||
to directory: URL
|
||||
) -> Bool {
|
||||
do {
|
||||
try fileManager.createDirectory(
|
||||
at: directory,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: nil
|
||||
)
|
||||
let data = try JSONEncoder().encode(record)
|
||||
var options: Data.WritingOptions = [.atomic]
|
||||
#if os(iOS)
|
||||
options.insert(.completeFileProtectionUntilFirstUserAuthentication)
|
||||
#endif
|
||||
let url = recordURL(messageID: messageID, in: directory)
|
||||
try data.write(to: url, options: options)
|
||||
return true
|
||||
} catch {
|
||||
SecureLogger.error(
|
||||
"❌ Failed to persist private-media receipt \(messageID.prefix(12))…: \(error)",
|
||||
category: .session
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func removeRecord(messageID: String, from directory: URL) {
|
||||
let url = recordURL(messageID: messageID, in: directory)
|
||||
guard fileManager.fileExists(atPath: url.path) else { return }
|
||||
do {
|
||||
try fileManager.removeItem(at: url)
|
||||
} catch {
|
||||
SecureLogger.warning(
|
||||
"⚠️ Failed to prune private-media receipt \(messageID.prefix(12))…: \(error)",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func recordURL(messageID: String, in directory: URL) -> URL {
|
||||
directory
|
||||
.appendingPathComponent(messageID, isDirectory: false)
|
||||
.appendingPathExtension("json")
|
||||
}
|
||||
|
||||
private func removePayloadRecordedByTombstone(_ record: ReceiptRecord) {
|
||||
guard record.kind == .tombstone,
|
||||
let relativePath = record.relativePath,
|
||||
let payload = candidatePayload(relativePath: relativePath),
|
||||
fileManager.fileExists(atPath: payload.path) else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
try fileManager.removeItem(at: payload)
|
||||
} catch {
|
||||
SecureLogger.warning(
|
||||
"⚠️ Failed to remove explicitly deleted private media: \(error)",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func addVolatileTombstone(_ messageID: String, at date: Date) {
|
||||
runtime.volatileTombstones[messageID] = date
|
||||
let overflow = runtime.volatileTombstones.count - capacity
|
||||
guard overflow > 0 else { return }
|
||||
let oldest = runtime.volatileTombstones.sorted {
|
||||
if $0.value == $1.value { return $0.key < $1.key }
|
||||
return $0.value < $1.value
|
||||
}
|
||||
for (oldMessageID, _) in oldest.prefix(overflow) {
|
||||
runtime.volatileTombstones.removeValue(forKey: oldMessageID)
|
||||
}
|
||||
}
|
||||
|
||||
private func validExistingPayload(_ url: URL) -> URL? {
|
||||
let standardized = url.standardizedFileURL
|
||||
guard isInsideFilesDirectory(standardized) else { return nil }
|
||||
var isDirectory: ObjCBool = false
|
||||
guard fileManager.fileExists(
|
||||
atPath: standardized.path,
|
||||
isDirectory: &isDirectory
|
||||
), !isDirectory.boolValue else {
|
||||
return nil
|
||||
}
|
||||
return standardized
|
||||
}
|
||||
|
||||
private func relativePath(for url: URL) -> String? {
|
||||
guard let filesRoot = try? filesDirectory().standardizedFileURL else {
|
||||
return nil
|
||||
}
|
||||
let prefix = filesRoot.path + "/"
|
||||
let standardized = url.standardizedFileURL
|
||||
guard standardized.path.hasPrefix(prefix) else { return nil }
|
||||
let relativePath = String(standardized.path.dropFirst(prefix.count))
|
||||
return relativePath.isEmpty ? nil : relativePath
|
||||
}
|
||||
|
||||
private func existingPayload(relativePath: String) -> URL? {
|
||||
guard let candidate = candidatePayload(relativePath: relativePath) else {
|
||||
return nil
|
||||
}
|
||||
return validExistingPayload(candidate)
|
||||
}
|
||||
|
||||
private func candidatePayload(relativePath: String) -> URL? {
|
||||
guard !relativePath.isEmpty,
|
||||
let filesRoot = try? filesDirectory().standardizedFileURL else {
|
||||
return nil
|
||||
}
|
||||
let candidate = filesRoot
|
||||
.appendingPathComponent(relativePath, isDirectory: false)
|
||||
.standardizedFileURL
|
||||
guard candidate.path.hasPrefix(filesRoot.path + "/") else { return nil }
|
||||
return candidate
|
||||
}
|
||||
|
||||
private func isInsideFilesDirectory(_ url: URL) -> Bool {
|
||||
guard let filesRoot = try? filesDirectory().standardizedFileURL else {
|
||||
return false
|
||||
}
|
||||
return url.standardizedFileURL.path.hasPrefix(filesRoot.path + "/")
|
||||
}
|
||||
|
||||
private func resolvedReceiptDirectory() -> URL? {
|
||||
return try? filesDirectory().appendingPathComponent(
|
||||
Self.receiptDirectoryName,
|
||||
isDirectory: true
|
||||
)
|
||||
}
|
||||
|
||||
private func filesDirectory() throws -> URL {
|
||||
let root = try baseDirectory ?? fileManager.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
let files = root.appendingPathComponent("files", isDirectory: true)
|
||||
try fileManager.createDirectory(
|
||||
at: files,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: nil
|
||||
)
|
||||
return files
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -703,6 +707,7 @@ final class BLEService: NSObject {
|
||||
/// or advertising while the full panic transaction is incomplete.
|
||||
func suspendForPanicReset() {
|
||||
setPanicSuspended(true)
|
||||
noisePacketHandler.resetForPanic()
|
||||
gossipSyncManager?.stop()
|
||||
gossipSyncManager = nil
|
||||
// Stop the radio and drain CoreBluetooth's delegate queue first. A
|
||||
@@ -713,7 +718,11 @@ final class BLEService: NSObject {
|
||||
// Drain every receive/send submitted by callbacks that finished ahead
|
||||
// of the radio stop. Later callbacks observe the closed lifecycle, and
|
||||
// generation-bound handoffs that raced this barrier reject themselves.
|
||||
messageQueue.sync(flags: .barrier) {}
|
||||
// Clear the old identity's bounded early-ciphertext queue again after
|
||||
// those callbacks drain so none can repopulate it after the first wipe.
|
||||
messageQueue.sync(flags: .barrier) {
|
||||
noisePacketHandler.resetForPanic()
|
||||
}
|
||||
clearEmergencySessionState()
|
||||
}
|
||||
|
||||
@@ -732,6 +741,7 @@ final class BLEService: NSObject {
|
||||
gossipSyncManager?.stop()
|
||||
gossipSyncManager = nil
|
||||
messageQueue.sync(flags: .barrier) {
|
||||
noisePacketHandler.resetForPanic()
|
||||
pendingNoiseSessionQueues.removeAll()
|
||||
}
|
||||
|
||||
@@ -764,6 +774,8 @@ final class BLEService: NSObject {
|
||||
|
||||
bleQueue.sync {
|
||||
pendingWriteBuffers.removeAll()
|
||||
noiseAuthenticatedLinkOwners.removeAll()
|
||||
noiseReconnectPolicy.removeAll()
|
||||
connectionScheduler.reset()
|
||||
}
|
||||
disconnectNotifyDebouncer.removeAll()
|
||||
@@ -1061,6 +1073,7 @@ final class BLEService: NSObject {
|
||||
bleQueue.sync {
|
||||
linkStateStore.clearAll()
|
||||
noiseAuthenticatedLinkOwners.removeAll()
|
||||
noiseReconnectPolicy.removeAll()
|
||||
connectionScheduler.reset()
|
||||
subscriptionAnnounceLimiter.removeAll()
|
||||
}
|
||||
@@ -1106,6 +1119,51 @@ final class BLEService: NSObject {
|
||||
collectionsQueue.sync { peerRegistry.capabilities(for: peerID) }
|
||||
}
|
||||
|
||||
func authenticatedPrivateMediaReceiptSessionGeneration(
|
||||
to peerID: PeerID
|
||||
) -> UUID? {
|
||||
let normalizedPeerID = peerID.toShort()
|
||||
let currentNoiseGeneration =
|
||||
noiseService.sessionGeneration(for: normalizedPeerID)
|
||||
return collectionsQueue.sync {
|
||||
guard let generation =
|
||||
privateMediaSessionGenerations[normalizedPeerID],
|
||||
generation == currentNoiseGeneration,
|
||||
let authenticated =
|
||||
authenticatedPeerStates[normalizedPeerID],
|
||||
authenticated.sessionGeneration == generation,
|
||||
authenticated.capabilities.contains(.privateMedia),
|
||||
authenticated.capabilities.contains(
|
||||
.privateMediaReceipts
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
return generation
|
||||
}
|
||||
}
|
||||
|
||||
private func privateMediaPolicyFingerprint(
|
||||
for peerID: PeerID,
|
||||
expectedSessionGeneration: UUID?
|
||||
) -> String? {
|
||||
let normalizedPeerID = peerID.toShort()
|
||||
if let expectedSessionGeneration,
|
||||
noiseService.sessionGeneration(for: normalizedPeerID)
|
||||
== expectedSessionGeneration,
|
||||
let fingerprint = noiseService.getPeerFingerprint(normalizedPeerID),
|
||||
noiseService.sessionGeneration(for: normalizedPeerID)
|
||||
== expectedSessionGeneration {
|
||||
// The exact authenticated Noise static key is stronger than a
|
||||
// registry entry populated by a public announce.
|
||||
return fingerprint
|
||||
}
|
||||
return collectionsQueue.sync {
|
||||
peerRegistry.info(for: normalizedPeerID)?
|
||||
.noisePublicKey?
|
||||
.sha256Fingerprint()
|
||||
}
|
||||
}
|
||||
|
||||
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
|
||||
let normalizedPeerID = peerID.toShort()
|
||||
let state: (
|
||||
@@ -1133,7 +1191,10 @@ final class BLEService: NSObject {
|
||||
return .awaitingCapabilityProof
|
||||
}
|
||||
|
||||
guard let fingerprint = state.fingerprint else {
|
||||
guard let fingerprint = privateMediaPolicyFingerprint(
|
||||
for: normalizedPeerID,
|
||||
expectedSessionGeneration: state.sessionGeneration
|
||||
) ?? state.fingerprint else {
|
||||
// A raw fallback must be bound to the stable Noise key from a
|
||||
// verified registry entry; a routing ID alone can rotate or be
|
||||
// spoofed. Without that key neither proof nor safe migration state
|
||||
@@ -1187,11 +1248,13 @@ final class BLEService: NSObject {
|
||||
return
|
||||
}
|
||||
|
||||
let fingerprint: String? = self.collectionsQueue.sync {
|
||||
self.peerRegistry.info(for: normalizedPeerID)?
|
||||
.noisePublicKey?
|
||||
.sha256Fingerprint()
|
||||
let generation = self.collectionsQueue.sync {
|
||||
self.privateMediaSessionGenerations[normalizedPeerID]
|
||||
}
|
||||
let fingerprint = self.privateMediaPolicyFingerprint(
|
||||
for: normalizedPeerID,
|
||||
expectedSessionGeneration: generation
|
||||
)
|
||||
guard let fingerprint else {
|
||||
self.completePrivateMediaPolicyResolution([completion], with: .blockedDowngrade)
|
||||
return
|
||||
@@ -1563,6 +1626,36 @@ final class BLEService: NSObject {
|
||||
to peerID: PeerID,
|
||||
transferId: String,
|
||||
allowLegacyFallback: Bool
|
||||
) {
|
||||
sendFilePrivate(
|
||||
filePacket,
|
||||
to: peerID,
|
||||
transferId: transferId,
|
||||
allowLegacyFallback: allowLegacyFallback,
|
||||
requiresAuthenticatedPrivateMediaReceipts: false
|
||||
)
|
||||
}
|
||||
|
||||
func sendFilePrivateReceiptRetry(
|
||||
_ filePacket: BitchatFilePacket,
|
||||
to peerID: PeerID,
|
||||
transferId: String
|
||||
) {
|
||||
sendFilePrivate(
|
||||
filePacket,
|
||||
to: peerID,
|
||||
transferId: transferId,
|
||||
allowLegacyFallback: false,
|
||||
requiresAuthenticatedPrivateMediaReceipts: true
|
||||
)
|
||||
}
|
||||
|
||||
private func sendFilePrivate(
|
||||
_ filePacket: BitchatFilePacket,
|
||||
to peerID: PeerID,
|
||||
transferId: String,
|
||||
allowLegacyFallback: Bool,
|
||||
requiresAuthenticatedPrivateMediaReceipts: Bool
|
||||
) {
|
||||
// Register before enqueueing onto messageQueue. This closes the window
|
||||
// where cancel/delete could run first, observe no scheduler state, and
|
||||
@@ -1675,6 +1768,25 @@ final class BLEService: NSObject {
|
||||
self.privateMediaTransferAdmissions.finish(transferId)
|
||||
return
|
||||
}
|
||||
if requiresAuthenticatedPrivateMediaReceipts,
|
||||
self.authenticatedPrivateMediaReceiptSessionGeneration(
|
||||
to: targetID
|
||||
) == nil {
|
||||
SecureLogger.warning(
|
||||
"Private media retry blocked without current authenticated receipt support for \(targetID.id.prefix(8))…",
|
||||
category: .security
|
||||
)
|
||||
TransferProgressManager.shared.rejectBeforeStart(
|
||||
id: transferId,
|
||||
reason: String(
|
||||
localized: "content.delivery.reason.private_media_capability_unresolved",
|
||||
defaultValue: "Could not confirm encrypted media support",
|
||||
comment: "Failure reason when private-media capability negotiation did not resolve"
|
||||
)
|
||||
)
|
||||
self.privateMediaTransferAdmissions.finish(transferId)
|
||||
return
|
||||
}
|
||||
guard let typedPayload = BLENoisePayloadFactory.privateFile(filePacket) else {
|
||||
SecureLogger.error("❌ Failed to encode file packet for private send", category: .session)
|
||||
TransferProgressManager.shared.rejectBeforeStart(
|
||||
@@ -1685,6 +1797,21 @@ final class BLEService: NSObject {
|
||||
return
|
||||
}
|
||||
guard self.noiseService.hasEstablishedSession(with: targetID) else {
|
||||
if requiresAuthenticatedPrivateMediaReceipts {
|
||||
// A retry belongs to one exact authenticated generation.
|
||||
// Never let it enter the ordinary pending queue where a
|
||||
// bit-8-only replacement session could later flush it.
|
||||
TransferProgressManager.shared.rejectBeforeStart(
|
||||
id: transferId,
|
||||
reason: String(
|
||||
localized: "content.delivery.reason.private_media_capability_unresolved",
|
||||
defaultValue: "Could not confirm encrypted media support",
|
||||
comment: "Failure reason when private-media capability negotiation did not resolve"
|
||||
)
|
||||
)
|
||||
self.privateMediaTransferAdmissions.finish(transferId)
|
||||
return
|
||||
}
|
||||
let queued = self.collectionsQueue.sync(flags: .barrier) {
|
||||
self.privateMediaTransferAdmissions.withActive(transferId) {
|
||||
self.pendingNoiseSessionQueues.appendTypedPayload(
|
||||
@@ -1716,7 +1843,12 @@ final class BLEService: NSObject {
|
||||
self.privateMediaTransferAdmissions.finish(transferId)
|
||||
return
|
||||
}
|
||||
let packet = try self.makeEncryptedNoisePacket(typedPayload, to: targetID)
|
||||
let packet = try self.makeEncryptedNoisePacket(
|
||||
typedPayload,
|
||||
to: targetID,
|
||||
requiresAuthenticatedPrivateMediaReceipts:
|
||||
requiresAuthenticatedPrivateMediaReceipts
|
||||
)
|
||||
guard self.privateMediaTransferAdmissions.isActive(transferId) else {
|
||||
self.privateMediaTransferAdmissions.finish(transferId)
|
||||
return
|
||||
@@ -1950,26 +2082,15 @@ final class BLEService: NSObject {
|
||||
// Encode once using a small per-type padding policy, then delegate by type
|
||||
let padForBLE = BLEOutboundPacketPolicy.padsBLEFrame(for: packetToSend.type)
|
||||
|
||||
// The 256-fragment ceiling exists to protect *current Android*
|
||||
// receivers, which only ever receive private media over the directed
|
||||
// raw-file migration fallback (they do not implement the encrypted
|
||||
// 0x20 path). Encrypted private media (`noiseEncrypted`) is sent only to
|
||||
// peers that advertised the `.privateMedia` capability — modern clients
|
||||
// that assemble up to the full receiver ceiling (see
|
||||
// `BLEFragmentAssemblyBuffer`'s 10,000-fragment guard) — so forcing them
|
||||
// down to Android's 256 cap would needlessly reject iOS→iOS photos in
|
||||
// the ~120–512 KiB range that work today. Restrict the low cap to the
|
||||
// migration fallback (directed `fileTransfer`); public media is
|
||||
// unaffected. Run the same planner the scheduler will use, after route
|
||||
// application, and reject before reserving a transfer slot or writing
|
||||
// any fragment.
|
||||
// TODO(#1434): negotiate an explicit per-peer fragment limit so a future
|
||||
// Android client that adopts the encrypted 0x20 path but still caps its
|
||||
// reassembler can advertise its own ceiling instead of relying on the
|
||||
// capability/type proxy above.
|
||||
// Cross-platform private-media v1 is bounded by Android's deployed
|
||||
// 256-fragment receive cap. Run the same planner the scheduler will
|
||||
// use, after route application, for both encrypted and consented raw
|
||||
// migration sends. Reject before reserving a transfer slot or writing
|
||||
// any fragment; public media is intentionally unaffected.
|
||||
if let transferId,
|
||||
let recipientPeerID = PeerID(hexData: packetToSend.recipientID),
|
||||
packetToSend.type == MessageType.fileTransfer.rawValue {
|
||||
packetToSend.type == MessageType.noiseEncrypted.rawValue
|
||||
|| packetToSend.type == MessageType.fileTransfer.rawValue {
|
||||
let compatibilityRequest = BLEOutboundFragmentTransferRequest(
|
||||
packet: packetToSend,
|
||||
pad: padForBLE,
|
||||
@@ -2525,12 +2646,50 @@ final class BLEService: NSObject {
|
||||
defaultPrefix: defaultPrefix
|
||||
)
|
||||
},
|
||||
privateMediaReceiptState: { [weak self] messageID in
|
||||
self?.incomingFileStore.privateMediaReceiptState(
|
||||
messageID: messageID
|
||||
) ?? .unavailable
|
||||
},
|
||||
commitPrivateMediaFile: { [weak self] messageID, storedURL in
|
||||
self?.incomingFileStore.commitPrivateMediaFile(
|
||||
messageID: messageID,
|
||||
storedURL: storedURL
|
||||
) ?? false
|
||||
},
|
||||
removeIncomingFile: { [weak self] storedURL in
|
||||
self?.incomingFileStore.removeIncomingFile(at: storedURL)
|
||||
},
|
||||
isPrivateMediaSenderBlocked: { [weak self] peerID in
|
||||
guard let self else { return false }
|
||||
let senderStaticKey = self.noiseService.getPeerPublicKeyData(peerID)
|
||||
?? self.collectionsQueue.sync {
|
||||
self.peerRegistry.info(for: peerID)?.noisePublicKey
|
||||
}
|
||||
guard let senderStaticKey else { return false }
|
||||
return self.identityManager.isBlocked(
|
||||
fingerprint: senderStaticKey.sha256Fingerprint()
|
||||
)
|
||||
},
|
||||
updatePeerLastSeen: { [weak self] peerID in
|
||||
self?.updatePeerLastSeen(peerID)
|
||||
},
|
||||
deliverMessage: { [weak self] message in
|
||||
// Single main-actor hop delivering `.messageReceived`.
|
||||
self?.emitTransportEvent(.messageReceived(message))
|
||||
acknowledgePrivateMedia: { [weak self] messageID, peerID in
|
||||
guard let self,
|
||||
let senderStaticKey = self.noiseService.getPeerPublicKeyData(peerID),
|
||||
!self.identityManager.isBlocked(
|
||||
fingerprint: senderStaticKey.sha256Fingerprint()
|
||||
) else {
|
||||
return
|
||||
}
|
||||
self.sendDeliveryAck(for: messageID, to: peerID)
|
||||
},
|
||||
deliverMessage: { [weak self] message, shouldDeliver, completion in
|
||||
self?.emitTransportEvent(
|
||||
.messageReceived(message),
|
||||
shouldDeliver: shouldDeliver,
|
||||
completion: completion
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -2628,6 +2787,7 @@ final class BLEService: NSObject {
|
||||
}
|
||||
for link in departedLinks {
|
||||
noiseAuthenticatedLinkOwners.removeValue(forKey: link)
|
||||
noiseReconnectPolicy.endLinkEpoch(link)
|
||||
}
|
||||
}
|
||||
_ = collectionsQueue.sync(flags: .barrier) {
|
||||
@@ -2940,14 +3100,21 @@ extension BLEService: CBCentralManagerDelegate {
|
||||
startScanning()
|
||||
|
||||
case .poweredOff:
|
||||
// Bluetooth was turned off - stop scanning and clean up connection state
|
||||
// CoreBluetooth has already transitioned out of poweredOn. Do
|
||||
// not issue stop/cancel commands now; they are rejected as API
|
||||
// misuse. Retire our link state locally instead.
|
||||
SecureLogger.info("📴 Bluetooth powered off - cleaning up central state", category: .session)
|
||||
central.stopScan()
|
||||
// Mark all peripheral connections as disconnected (they are now invalid)
|
||||
let peripheralStates = linkStateStore.peripheralStates
|
||||
let peerIDs: [PeerID] = peripheralStates.compactMap(\.peerID)
|
||||
for state in peripheralStates {
|
||||
central.cancelPeripheralConnection(state.peripheral)
|
||||
let peripheralID = state.peripheral.identifier.uuidString
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
pendingPeripheralWrites.discardAll(for: peripheralID)
|
||||
}
|
||||
noiseAuthenticatedLinkOwners.removeValue(
|
||||
forKey: .peripheral(peripheralID)
|
||||
)
|
||||
noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
|
||||
}
|
||||
_ = linkStateStore.clearPeripherals()
|
||||
// Notify UI of disconnections
|
||||
@@ -2960,7 +3127,6 @@ extension BLEService: CBCentralManagerDelegate {
|
||||
case .unauthorized:
|
||||
// User denied Bluetooth permission
|
||||
SecureLogger.warning("🚫 Bluetooth unauthorized - user denied permission", category: .session)
|
||||
central.stopScan()
|
||||
_ = linkStateStore.clearPeripherals()
|
||||
|
||||
case .unsupported:
|
||||
@@ -3108,6 +3274,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
|
||||
@@ -3162,6 +3329,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)
|
||||
@@ -3269,6 +3437,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()
|
||||
@@ -3550,6 +3719,28 @@ extension BLEService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replays the current generation's ready callback. Restore tests use
|
||||
/// this to prove same-generation reconciliation is idempotent.
|
||||
func _test_reconcileCurrentNoiseSession(for peerID: PeerID) {
|
||||
let normalizedPeerID = peerID.toShort()
|
||||
messageQueue.async(flags: .barrier) { [weak self] in
|
||||
guard let self,
|
||||
let generation = self.noiseService.sessionGeneration(
|
||||
for: normalizedPeerID
|
||||
),
|
||||
let fingerprint = self.noiseService.getPeerFingerprint(
|
||||
normalizedPeerID
|
||||
) else {
|
||||
return
|
||||
}
|
||||
self.handleNoisePeerAuthenticated(
|
||||
peerID: normalizedPeerID,
|
||||
fingerprint: fingerprint,
|
||||
sessionGeneration: generation
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds an authenticated-session packet from an exact typed plaintext.
|
||||
/// Compatibility tests use this to model Android's deployed 0x20 file
|
||||
/// payload and the short-lived 0x09 prerelease payload without exposing a
|
||||
@@ -3849,8 +4040,19 @@ extension BLEService: CBPeripheralManagerDelegate {
|
||||
case .poweredOff:
|
||||
// Bluetooth was turned off - clean up peripheral state
|
||||
SecureLogger.info("📴 Bluetooth powered off - cleaning up peripheral state", category: .session)
|
||||
peripheral.stopAdvertising()
|
||||
// Clear subscribed centrals (they are now invalid)
|
||||
let centralSnapshot = linkStateStore.subscribedCentralSnapshot
|
||||
for central in centralSnapshot.centrals {
|
||||
let centralID = central.identifier.uuidString
|
||||
noiseAuthenticatedLinkOwners.removeValue(
|
||||
forKey: .central(centralID)
|
||||
)
|
||||
noiseReconnectPolicy.endLinkEpoch(.central(centralID))
|
||||
}
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
pendingNotifications.removeAll()
|
||||
pendingWriteBuffers.removeAll()
|
||||
}
|
||||
let centralPeerIDs = linkStateStore.clearCentrals()
|
||||
subscriptionAnnounceLimiter.removeAll()
|
||||
characteristic = nil
|
||||
@@ -3864,7 +4066,6 @@ extension BLEService: CBPeripheralManagerDelegate {
|
||||
case .unauthorized:
|
||||
// User denied Bluetooth permission
|
||||
SecureLogger.warning("🚫 Bluetooth unauthorized for peripheral role", category: .session)
|
||||
peripheral.stopAdvertising()
|
||||
_ = linkStateStore.clearCentrals()
|
||||
subscriptionAnnounceLimiter.removeAll()
|
||||
characteristic = nil
|
||||
@@ -3977,6 +4178,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
|
||||
@@ -4204,18 +4406,55 @@ extension BLEService {
|
||||
}
|
||||
}
|
||||
|
||||
private func emitTransportEvent(_ event: TransportEvent) {
|
||||
private func emitTransportEvent(
|
||||
_ event: TransportEvent,
|
||||
shouldDeliver: (() -> Bool)? = nil,
|
||||
completion: (() -> Void)? = nil
|
||||
) {
|
||||
notifyUI { [weak self] in
|
||||
self?.deliverTransportEvent(event)
|
||||
guard let self,
|
||||
shouldDeliver?() ?? true,
|
||||
self.deliverTransportEvent(event),
|
||||
// Quota cleanup can race the asynchronous main-actor hop or
|
||||
// the synchronous ConversationStore upsert. ACK only while
|
||||
// the exact durable mapping and file still resolve.
|
||||
shouldDeliver?() ?? true else {
|
||||
return
|
||||
}
|
||||
completion?()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func deliverTransportEvent(_ event: TransportEvent) {
|
||||
@discardableResult
|
||||
private func deliverTransportEvent(_ event: TransportEvent) -> Bool {
|
||||
if case .messageReceived(let message) = event {
|
||||
if let synchronousDelegate =
|
||||
eventDelegate as? SynchronousMessageTransportEventDelegate {
|
||||
return synchronousDelegate
|
||||
.didReceiveTransportMessageSynchronously(message)
|
||||
}
|
||||
if let eventDelegate {
|
||||
eventDelegate.didReceiveTransportEvent(event)
|
||||
return false
|
||||
}
|
||||
if let synchronousDelegate =
|
||||
delegate as? SynchronousMessageTransportEventDelegate {
|
||||
return synchronousDelegate
|
||||
.didReceiveTransportMessageSynchronously(message)
|
||||
}
|
||||
}
|
||||
|
||||
if let eventDelegate {
|
||||
eventDelegate.didReceiveTransportEvent(event)
|
||||
return true
|
||||
} else {
|
||||
delegate?.receiveTransportEvent(event)
|
||||
guard let delegate else { return false }
|
||||
delegate.receiveTransportEvent(event)
|
||||
if case .messageReceived = event {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4594,11 +4833,48 @@ 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
|
||||
SecureLogger.debug("🔐 Noise session authenticated with \(peerID.id.prefix(8))…, fingerprint: \(fingerprint.prefix(16))…")
|
||||
self?.messageQueue.async { [weak self] in
|
||||
// Authentication can be reported while an initiator is still
|
||||
// returning XX message 3. Serialize generation-bound state and
|
||||
// every post-handshake drain behind the handshake packet handler.
|
||||
self?.messageQueue.async(flags: .barrier) { [weak self] in
|
||||
self?.handleNoisePeerAuthenticated(
|
||||
peerID: peerID,
|
||||
fingerprint: fingerprint,
|
||||
@@ -4606,13 +4882,96 @@ extension BLEService {
|
||||
)
|
||||
}
|
||||
}
|
||||
service.onRekeyHandshakeReady = { [weak self] peerID, message in
|
||||
self?.messageQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
service.onRekeyHandshakeReady = {
|
||||
[weak self, weak service] peerID, initiation in
|
||||
self?.messageQueue.async(flags: .barrier) {
|
||||
[weak self, weak service] in
|
||||
guard let self,
|
||||
let service,
|
||||
self.noiseService === service else {
|
||||
return
|
||||
}
|
||||
self.noteNoiseSessionCleared(for: peerID)
|
||||
guard let message = service.claimHandshakeInitiation(
|
||||
initiation,
|
||||
for: peerID
|
||||
) else {
|
||||
return
|
||||
}
|
||||
self.broadcastNoiseHandshake(message, to: peerID)
|
||||
}
|
||||
}
|
||||
service.onHandshakeRecoveryRequired = {
|
||||
[weak self, weak service] request in
|
||||
guard let self, let service else { return }
|
||||
self.messageQueue.async(flags: .barrier) {
|
||||
[weak self, weak service] in
|
||||
guard let self,
|
||||
let service,
|
||||
self.noiseService === service else {
|
||||
return
|
||||
}
|
||||
let peerID = request.peerID
|
||||
guard self.isPeerReachable(peerID) else {
|
||||
service.cancelHandshakeRecovery(request)
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
guard let preparation =
|
||||
try service.prepareHandshakeRecovery(request) else {
|
||||
return
|
||||
}
|
||||
switch preparation {
|
||||
case .ordinary(let initiation):
|
||||
self.noteNoiseSessionCleared(for: peerID)
|
||||
guard let handshakeData =
|
||||
service.claimHandshakeInitiation(
|
||||
initiation,
|
||||
for: peerID
|
||||
) else {
|
||||
return
|
||||
}
|
||||
self.broadcastNoiseHandshake(
|
||||
handshakeData,
|
||||
to: peerID
|
||||
)
|
||||
case .transferred:
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error(
|
||||
"Failed to prepare handshake recovery with \(peerID.id.prefix(8))…: \(error)",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
service.onSessionRestoredWithGeneration = { [weak self, weak service] peerID, generation in
|
||||
guard let self, let service else { return }
|
||||
// The manager makes restored keys visible atomically. Reconcile
|
||||
// transport state and queued sends as the next serialized phase.
|
||||
self.messageQueue.async(flags: .barrier) { [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(
|
||||
@@ -4660,7 +5019,16 @@ extension BLEService {
|
||||
}
|
||||
) else { return }
|
||||
|
||||
guard let watchdog = transition.watchdog else { return }
|
||||
guard let watchdog = transition.watchdog else {
|
||||
// A quarantined transport restored the same cryptographic
|
||||
// generation. Its capability proof and announce state never
|
||||
// became stale; only work queued while outbound keys were paused
|
||||
// needs one idempotent ready transition.
|
||||
noisePacketHandler.handleSessionAuthenticated(normalizedPeerID)
|
||||
sendPendingMessagesAfterHandshake(for: normalizedPeerID)
|
||||
sendPendingNoisePayloadsAfterHandshake(for: normalizedPeerID)
|
||||
return
|
||||
}
|
||||
|
||||
completePrivateMediaPolicyResolution(transition.rejected, with: .blockedDowngrade)
|
||||
schedulePrivateMediaProofTimeout(
|
||||
@@ -4669,6 +5037,10 @@ extension BLEService {
|
||||
sessionGeneration: generation,
|
||||
nonce: watchdog.nonce
|
||||
)
|
||||
// Cross-link delivery can put ciphertext sent immediately after
|
||||
// message 3 ahead of message 3 itself. Retry the bounded queue only
|
||||
// after this generation's transport state has been fully installed.
|
||||
noisePacketHandler.handleSessionAuthenticated(normalizedPeerID)
|
||||
|
||||
// `onPeerAuthenticated` can fire while the initiator is returning XX
|
||||
// message 3. This callback is queued behind the handshake handler, so
|
||||
@@ -4866,8 +5238,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)
|
||||
@@ -4883,7 +5256,11 @@ extension BLEService {
|
||||
}
|
||||
}
|
||||
|
||||
private func makeEncryptedNoisePacket(_ typedPayload: Data, to peerID: PeerID) throws -> BitchatPacket {
|
||||
private func makeEncryptedNoisePacket(
|
||||
_ typedPayload: Data,
|
||||
to peerID: PeerID,
|
||||
requiresAuthenticatedPrivateMediaReceipts: Bool = false
|
||||
) throws -> BitchatPacket {
|
||||
let encrypted: Data
|
||||
let isPrivateFile = NoisePayloadType.isPrivateFile(rawValue: typedPayload.first)
|
||||
if isPrivateFile {
|
||||
@@ -4893,6 +5270,13 @@ extension BLEService {
|
||||
let authenticated = authenticatedPeerStates[peerID],
|
||||
authenticated.sessionGeneration == generation,
|
||||
authenticated.capabilities.contains(.privateMedia) else { return nil }
|
||||
if requiresAuthenticatedPrivateMediaReceipts {
|
||||
guard authenticated.capabilities.contains(
|
||||
.privateMediaReceipts
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return generation
|
||||
}
|
||||
guard let provenGeneration else {
|
||||
@@ -5797,6 +6181,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
|
||||
}
|
||||
@@ -5862,12 +6247,27 @@ extension BLEService {
|
||||
}
|
||||
|
||||
private func initiateNoiseHandshake(with peerID: PeerID) {
|
||||
// Use NoiseEncryptionService for handshake
|
||||
guard !noiseService.hasSession(with: peerID) else { return }
|
||||
|
||||
let service = noiseService
|
||||
do {
|
||||
let handshakeData = try noiseService.initiateHandshake(with: peerID)
|
||||
broadcastNoiseHandshake(handshakeData, to: peerID)
|
||||
guard let initiation = try service.initiateHandshakeIfNeeded(
|
||||
with: peerID,
|
||||
retryOnTimeout: true
|
||||
) else {
|
||||
return
|
||||
}
|
||||
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 {
|
||||
SecureLogger.error("Failed to initiate handshake: \(error)")
|
||||
}
|
||||
@@ -5885,6 +6285,42 @@ 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,
|
||||
retryOnTimeout: true
|
||||
)
|
||||
messageQueue.async(flags: .barrier) { [weak self, weak service] in
|
||||
guard let self,
|
||||
let service,
|
||||
self.noiseService === service else {
|
||||
return
|
||||
}
|
||||
self.noteNoiseSessionCleared(for: peerID)
|
||||
guard 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)
|
||||
@@ -6230,7 +6666,12 @@ extension BLEService {
|
||||
// MARK: Packet Reception
|
||||
|
||||
private func handleReceivedPacket(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
// Call directly if already on messageQueue, otherwise dispatch
|
||||
let isNoisePacket = packet.type == MessageType.noiseHandshake.rawValue
|
||||
|| packet.type == MessageType.noiseEncrypted.rawValue
|
||||
|
||||
// Capture the panic lifecycle at the first off-messageQueue handoff.
|
||||
// Noise packets still enter through a barrier so handshake promotion,
|
||||
// quarantine, and encrypted delivery share one ordered session.
|
||||
if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
|
||||
guard let lifecycleGeneration =
|
||||
capturePanicLifecycleGeneration() else {
|
||||
@@ -6239,7 +6680,8 @@ extension BLEService {
|
||||
#if DEBUG
|
||||
_test_beforeReceivePacketHandoff?()
|
||||
#endif
|
||||
messageQueue.async { [weak self] in
|
||||
let flags: DispatchWorkItemFlags = isNoisePacket ? .barrier : []
|
||||
messageQueue.async(flags: flags) { [weak self] in
|
||||
guard let self,
|
||||
self.isCurrentPanicLifecycleGeneration(
|
||||
lifecycleGeneration
|
||||
@@ -6249,11 +6691,34 @@ extension BLEService {
|
||||
#if DEBUG
|
||||
self._test_onReceivePacketHandoff?()
|
||||
#endif
|
||||
self.handleReceivedPacket(packet, from: peerID)
|
||||
self.handleReceivedPacketOnQueue(packet, from: peerID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if isNoisePacket {
|
||||
guard let lifecycleGeneration =
|
||||
capturePanicLifecycleGeneration() else {
|
||||
return
|
||||
}
|
||||
messageQueue.async(flags: .barrier) { [weak self] in
|
||||
guard let self,
|
||||
self.isCurrentPanicLifecycleGeneration(
|
||||
lifecycleGeneration
|
||||
) else {
|
||||
return
|
||||
}
|
||||
self.handleReceivedPacketOnQueue(packet, from: peerID)
|
||||
}
|
||||
} else {
|
||||
handleReceivedPacketOnQueue(packet, from: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleReceivedPacketOnQueue(
|
||||
_ packet: BitchatPacket,
|
||||
from peerID: PeerID
|
||||
) {
|
||||
let context = BLEReceivePipeline.context(for: packet, localPeerID: myPeerID)
|
||||
let senderID = context.senderID
|
||||
let messageID = context.messageID
|
||||
@@ -6440,6 +6905,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)
|
||||
}
|
||||
|
||||
@@ -6473,11 +6941,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)
|
||||
}
|
||||
@@ -6506,7 +6972,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"
|
||||
@@ -6533,12 +7006,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
|
||||
@@ -6630,6 +7111,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))…)" } ?? "")",
|
||||
@@ -7016,6 +7498,12 @@ extension BLEService {
|
||||
packet,
|
||||
from: peerID
|
||||
)
|
||||
// An inbound message 1 quarantines the old transport receive-only.
|
||||
// Keep its generation-bound BLE state intact: the manager's new
|
||||
// handshaking generation already gates every outbound policy, while
|
||||
// a rollback can become ready again without repeating capability
|
||||
// proof or announce side effects. Only the exact handshake candidate's
|
||||
// authenticated completion may promote the physical ingress link.
|
||||
if result.didEstablishAuthenticatedSession {
|
||||
markNoiseAuthenticatedIngressLink(for: packet, peerID: peerID)
|
||||
}
|
||||
@@ -7053,6 +7541,11 @@ extension BLEService {
|
||||
hasNoiseSession: { [weak self] peerID in
|
||||
self?.noiseService.hasSession(with: peerID) ?? false
|
||||
},
|
||||
isAwaitingResponderHandshakeCompletion: { [weak self] peerID in
|
||||
self?.noiseService.isAwaitingResponderHandshakeCompletion(
|
||||
with: peerID
|
||||
) ?? false
|
||||
},
|
||||
initiateHandshake: { [weak self] peerID in
|
||||
self?.initiateNoiseHandshake(with: peerID)
|
||||
},
|
||||
@@ -7066,7 +7559,14 @@ extension BLEService {
|
||||
guard let self = self else { throw NoiseEncryptionError.sessionNotEstablished }
|
||||
let result = try self.noiseService.decryptWithSessionGeneration(
|
||||
payload,
|
||||
from: peerID
|
||||
from: peerID,
|
||||
establishedGenerationIsReady: { generation in
|
||||
self.collectionsQueue.sync {
|
||||
self.privateMediaSessionGenerations[
|
||||
peerID.toShort()
|
||||
] == generation
|
||||
}
|
||||
}
|
||||
)
|
||||
return BLENoiseDecryptionResult(
|
||||
plaintext: result.plaintext,
|
||||
|
||||
@@ -184,11 +184,17 @@ final class NoiseEncryptionService {
|
||||
private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication
|
||||
private var onPeerAuthenticatedWithGenerationHandlers: [((PeerID, String, UUID) -> Void)] = []
|
||||
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
|
||||
/// Automatic rekey removed the old session and produced XX message 1.
|
||||
/// The transport must clear session-scoped state and put these exact bytes
|
||||
/// 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)?
|
||||
/// Automatic rekey prepared XX message 1. The transport must claim the
|
||||
/// exact attempt at its actual BLE handoff; a crossed inbound initiation
|
||||
/// can invalidate the token before that point.
|
||||
var onRekeyHandshakeReady:
|
||||
((_ peerID: PeerID, _ initiation: NoiseHandshakeInitiation) -> Void)?
|
||||
var onHandshakeRecoveryRequired:
|
||||
((_ request: NoiseHandshakeRecoveryRequest) -> 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 +225,17 @@ final class NoiseEncryptionService {
|
||||
}
|
||||
}
|
||||
|
||||
init(keychain: KeychainManagerProtocol) {
|
||||
init(
|
||||
keychain: KeychainManagerProtocol,
|
||||
ordinaryHandshakeTimeout: TimeInterval =
|
||||
NoiseSecurityConstants.ordinaryHandshakeTimeout,
|
||||
ordinaryResponderHandshakeTimeout: TimeInterval =
|
||||
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout,
|
||||
recentInitiatorCompletionGracePeriod: TimeInterval =
|
||||
NoiseSecurityConstants.recentInitiatorCompletionGracePeriod,
|
||||
ordinaryReconnectRollbackCooldown: TimeInterval =
|
||||
NoiseSecurityConstants.ordinaryReconnectRollbackCooldown
|
||||
) {
|
||||
self.keychain = keychain
|
||||
self.localPrekeys = LocalPrekeyStore(keychain: keychain)
|
||||
|
||||
@@ -309,7 +325,17 @@ final class NoiseEncryptionService {
|
||||
self.signingPublicKey = signingKey.publicKey
|
||||
|
||||
// Initialize session manager
|
||||
self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain)
|
||||
self.sessionManager = NoiseSessionManager(
|
||||
localStaticKey: staticIdentityKey,
|
||||
keychain: keychain,
|
||||
ordinaryHandshakeTimeout: ordinaryHandshakeTimeout,
|
||||
ordinaryResponderHandshakeTimeout:
|
||||
ordinaryResponderHandshakeTimeout,
|
||||
recentInitiatorCompletionGracePeriod:
|
||||
recentInitiatorCompletionGracePeriod,
|
||||
ordinaryReconnectRollbackCooldown:
|
||||
ordinaryReconnectRollbackCooldown
|
||||
)
|
||||
|
||||
// Set up session callbacks
|
||||
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey, generation in
|
||||
@@ -319,6 +345,12 @@ final class NoiseEncryptionService {
|
||||
sessionGeneration: generation
|
||||
)
|
||||
}
|
||||
sessionManager.onSessionRestored = { [weak self] peerID, generation in
|
||||
self?.onSessionRestoredWithGeneration?(peerID, generation)
|
||||
}
|
||||
sessionManager.onHandshakeRecoveryRequired = { [weak self] request in
|
||||
self?.onHandshakeRecoveryRequired?(request)
|
||||
}
|
||||
|
||||
// Start session maintenance timer
|
||||
startRekeyTimer()
|
||||
@@ -682,6 +714,90 @@ final class NoiseEncryptionService {
|
||||
let handshakeData = try sessionManager.initiateHandshake(with: peerID)
|
||||
return handshakeData
|
||||
}
|
||||
|
||||
/// Atomically admits and prepares one initial ordinary handshake. Returns
|
||||
/// nil when another discovery callback already created a session.
|
||||
func initiateHandshakeIfNeeded(
|
||||
with peerID: PeerID,
|
||||
retryOnTimeout: Bool = false
|
||||
) throws -> NoiseHandshakeInitiation? {
|
||||
guard peerID.isValid else {
|
||||
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
|
||||
throw NoiseSecurityError.invalidPeerID
|
||||
}
|
||||
|
||||
guard let initiation = try sessionManager.initiateHandshakeIfAbsent(
|
||||
with: peerID,
|
||||
notifyOnTimeout: retryOnTimeout,
|
||||
authorize: { [rateLimiter] in
|
||||
guard rateLimiter.allowHandshake(from: peerID) else {
|
||||
SecureLogger.warning(
|
||||
.authenticationFailed(peerID: "Rate limited: \(peerID)")
|
||||
)
|
||||
throw NoiseSecurityError.rateLimitExceeded
|
||||
}
|
||||
}
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
SecureLogger.info(.handshakeStarted(peerID: peerID.id))
|
||||
return initiation
|
||||
}
|
||||
|
||||
/// 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,
|
||||
retryOnTimeout: Bool = false
|
||||
) throws -> NoiseHandshakeInitiation {
|
||||
guard peerID.isValid else {
|
||||
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
|
||||
throw NoiseSecurityError.invalidPeerID
|
||||
}
|
||||
|
||||
return try sessionManager.initiateReconnectHandshake(
|
||||
with: peerID,
|
||||
notifyOnTimeout: retryOnTimeout,
|
||||
authorize: { [rateLimiter] in
|
||||
guard rateLimiter.allowHandshake(from: peerID) else {
|
||||
SecureLogger.warning(
|
||||
.authenticationFailed(peerID: "Rate limited: \(peerID)")
|
||||
)
|
||||
throw NoiseSecurityError.rateLimitExceeded
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func prepareHandshakeRecovery(
|
||||
_ request: NoiseHandshakeRecoveryRequest
|
||||
) throws -> NoiseHandshakeRecoveryPreparation? {
|
||||
try sessionManager.prepareHandshakeRecovery(
|
||||
request,
|
||||
authorizeAttempt: { [rateLimiter] in
|
||||
guard rateLimiter.allowHandshake(from: request.peerID) else {
|
||||
SecureLogger.warning(
|
||||
.authenticationFailed(
|
||||
peerID: "Rate limited: \(request.peerID)"
|
||||
)
|
||||
)
|
||||
throw NoiseSecurityError.rateLimitExceeded
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func cancelHandshakeRecovery(_ request: NoiseHandshakeRecoveryRequest) {
|
||||
sessionManager.cancelHandshakeRecovery(request)
|
||||
}
|
||||
|
||||
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? {
|
||||
@@ -737,6 +853,13 @@ final class NoiseEncryptionService {
|
||||
func hasSession(with peerID: PeerID) -> Bool {
|
||||
return sessionManager.getSession(for: peerID) != nil
|
||||
}
|
||||
|
||||
/// True while an inbound ordinary XX responder is waiting for message 3.
|
||||
/// A small amount of immediately-following ciphertext may arrive first
|
||||
/// over BLE and must be retried only after responder promotion.
|
||||
func isAwaitingResponderHandshakeCompletion(with peerID: PeerID) -> Bool {
|
||||
sessionManager.isAwaitingResponderHandshakeCompletion(for: peerID)
|
||||
}
|
||||
|
||||
// MARK: - Encryption/Decryption
|
||||
|
||||
@@ -804,27 +927,37 @@ final class NoiseEncryptionService {
|
||||
|
||||
func decryptWithSessionGeneration(
|
||||
_ data: Data,
|
||||
from peerID: PeerID
|
||||
from peerID: PeerID,
|
||||
establishedGenerationIsReady: (UUID) -> Bool = { _ in true }
|
||||
) throws -> (plaintext: Data, sessionGeneration: UUID) {
|
||||
// Standard transport ciphertext has 20 bytes of nonce/tag overhead.
|
||||
// A larger candidate is admitted only up to the framed-file ceiling;
|
||||
// A larger ciphertext is admitted only up to the framed-file ceiling;
|
||||
// after authenticated decryption it must prove it is `.privateFile`.
|
||||
let isStandardCiphertext = NoiseSecurityValidator.validateCiphertextSize(data)
|
||||
guard isStandardCiphertext || NoiseSecurityValidator.validatePrivateFileCiphertextSize(data) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
let isAdmittedCiphertext = isStandardCiphertext
|
||||
|| NoiseSecurityValidator.validatePrivateFileCiphertextSize(data)
|
||||
|
||||
// Check rate limit
|
||||
guard rateLimiter.allowMessage(from: peerID) else {
|
||||
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
|
||||
}
|
||||
|
||||
let result = try sessionManager.decryptWithSessionGeneration(data, from: peerID)
|
||||
let result = try sessionManager.decryptWithSessionGeneration(
|
||||
data,
|
||||
from: peerID,
|
||||
establishedGenerationIsReady:
|
||||
establishedGenerationIsReady,
|
||||
authorizeDecrypt: { [rateLimiter] in
|
||||
guard isAdmittedCiphertext else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
guard rateLimiter.allowMessage(from: peerID) else {
|
||||
throw NoiseSecurityError.rateLimitExceeded
|
||||
}
|
||||
}
|
||||
)
|
||||
if !isStandardCiphertext {
|
||||
guard NoisePayloadType.isPrivateFile(rawValue: result.plaintext.first),
|
||||
NoiseSecurityValidator.validatePrivateFileMessageSize(result.plaintext) else {
|
||||
@@ -943,9 +1076,9 @@ final class NoiseEncryptionService {
|
||||
}
|
||||
|
||||
private func initiateAutomaticRekey(for peerID: PeerID) throws {
|
||||
let handshakeMessage = try sessionManager.initiateRekey(for: peerID)
|
||||
let initiation = try sessionManager.initiateRekey(for: peerID)
|
||||
SecureLogger.debug("Key rotation initiated for peer: \(peerID)", category: .security)
|
||||
onRekeyHandshakeReady?(peerID, handshakeMessage)
|
||||
onRekeyHandshakeReady?(peerID, initiation)
|
||||
onHandshakeRequired?(peerID)
|
||||
}
|
||||
|
||||
@@ -1041,6 +1174,9 @@ struct NoiseMessage: Codable {
|
||||
enum NoiseEncryptionError: Error {
|
||||
case handshakeRequired
|
||||
case sessionNotEstablished
|
||||
/// Manager keys are established or restored, but BLE has not installed
|
||||
/// generation-bound transport state. No receive nonce was consumed.
|
||||
case transportGenerationNotReady
|
||||
/// Envelope references a prekey ID we don't hold (never ours, already
|
||||
/// deleted after its grace window, or wiped in a panic).
|
||||
case unknownPrekey
|
||||
|
||||
@@ -101,6 +101,13 @@ protocol TransportEventDelegate: AnyObject {
|
||||
@MainActor func didReceiveTransportEvent(_ event: TransportEvent)
|
||||
}
|
||||
|
||||
/// Optional typed-event contract for sinks that can synchronously decide
|
||||
/// whether an inbound message was accepted.
|
||||
protocol SynchronousMessageTransportEventDelegate: TransportEventDelegate {
|
||||
@MainActor
|
||||
func didReceiveTransportMessageSynchronously(_ message: BitchatMessage) -> Bool
|
||||
}
|
||||
|
||||
protocol Transport: AnyObject {
|
||||
// Event sink
|
||||
var delegate: BitchatDelegate? { get set }
|
||||
@@ -183,6 +190,14 @@ protocol Transport: AnyObject {
|
||||
transferId: String,
|
||||
allowLegacyFallback: Bool
|
||||
)
|
||||
/// Automatic whole-file retry is admitted only while this exact Noise
|
||||
/// generation authenticates bit 9. It must never queue across a session
|
||||
/// replacement or enter the signed raw legacy path.
|
||||
func sendFilePrivateReceiptRetry(
|
||||
_ packet: BitchatFilePacket,
|
||||
to peerID: PeerID,
|
||||
transferId: String
|
||||
)
|
||||
func cancelTransfer(_ transferId: String)
|
||||
|
||||
// Live voice / push-to-talk (mesh transports only): one encoded
|
||||
@@ -229,6 +244,11 @@ protocol Transport: AnyObject {
|
||||
/// empty for peers that predate the capabilities TLV.
|
||||
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities
|
||||
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy
|
||||
/// The exact current Noise generation that authenticated both encrypted
|
||||
/// private media (bit 8) and durable receipts/retry (bit 9).
|
||||
func authenticatedPrivateMediaReceiptSessionGeneration(
|
||||
to peerID: PeerID
|
||||
) -> UUID?
|
||||
func resolvePrivateMediaSendPolicy(
|
||||
to peerID: PeerID,
|
||||
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||
@@ -304,6 +324,11 @@ extension Transport {
|
||||
func broadcastGroupMessage(_ envelope: Data) {}
|
||||
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] }
|
||||
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { .blockedDowngrade }
|
||||
func authenticatedPrivateMediaReceiptSessionGeneration(
|
||||
to peerID: PeerID
|
||||
) -> UUID? {
|
||||
nil
|
||||
}
|
||||
func resolvePrivateMediaSendPolicy(
|
||||
to peerID: PeerID,
|
||||
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||
@@ -338,6 +363,11 @@ extension Transport {
|
||||
guard !allowLegacyFallback else { return }
|
||||
sendFilePrivate(packet, to: peerID, transferId: transferId)
|
||||
}
|
||||
func sendFilePrivateReceiptRetry(
|
||||
_ packet: BitchatFilePacket,
|
||||
to peerID: PeerID,
|
||||
transferId: String
|
||||
) {}
|
||||
func cancelTransfer(_ transferId: String) {}
|
||||
|
||||
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
|
||||
|
||||
@@ -15,6 +15,13 @@ enum TransportConfig {
|
||||
static let privateMediaCapabilityProofTimeoutSeconds: TimeInterval = 5
|
||||
static let privateMediaCapabilityProofPendingPeerCap: Int = 64
|
||||
static let privateMediaCapabilityProofWaitersPerPeerCap: Int = 16
|
||||
/// Accepted private-media receipts and explicit-deletion tombstones each
|
||||
/// receive this independent capacity.
|
||||
static let privateMediaReceivedLedgerCapacity: Int = 4_096
|
||||
/// A bounded retry horizon prevents stable receipt state from growing into
|
||||
/// permanent application history.
|
||||
static let privateMediaReceivedLedgerTTLSeconds: TimeInterval =
|
||||
7 * 24 * 60 * 60
|
||||
static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments
|
||||
static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays
|
||||
// Fragment relay TTL in sparse graphs; matches messageTTLDefault so media
|
||||
|
||||
@@ -55,6 +55,9 @@ extension ChatViewModel: ChatDeliveryContext {
|
||||
|
||||
func markMessageDelivered(_ messageID: String) {
|
||||
messageRouter.markDelivered(messageID)
|
||||
mediaTransferCoordinator.confirmPrivateMediaDelivery(
|
||||
messageID: messageID
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import Foundation
|
||||
@MainActor
|
||||
protocol ChatLiveVoiceContext: AnyObject {
|
||||
var nickname: String { get }
|
||||
var myPeerID: PeerID { get }
|
||||
var selectedPrivateChatPeer: PeerID? { get }
|
||||
/// Whether the public mesh timeline is what's on screen (autoplay gate
|
||||
/// for public bursts).
|
||||
@@ -30,6 +31,12 @@ protocol ChatLiveVoiceContext: AnyObject {
|
||||
func upsertPublicMeshMessage(_ message: BitchatMessage)
|
||||
@discardableResult
|
||||
func removePrivateMessage(withID messageID: String) -> BitchatMessage?
|
||||
/// Records and sends the finalized note's read receipt after a live
|
||||
/// bubble adopts its wire-derivable message ID.
|
||||
func hasSentReadReceipt(_ messageID: String) -> Bool
|
||||
@discardableResult
|
||||
func markReadReceiptSent(_ messageID: String) -> Bool
|
||||
func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
|
||||
/// Removes a message from whichever conversation holds it.
|
||||
func removeMessage(withID messageID: String, cleanupFile: Bool)
|
||||
/// Publishes who is currently talking live in the public mesh channel
|
||||
@@ -272,8 +279,16 @@ final class ChatLiveVoiceCoordinator {
|
||||
guard let entry = finishedBursts.first(where: { matches($0.key) }) else { return false }
|
||||
let finished = entry.value
|
||||
|
||||
// A DM live bubble starts before the finalized file exists and
|
||||
// therefore has a receiver-local random ID. Adopt the finalized
|
||||
// message's deterministic ID so delivery/read ACKs address the same
|
||||
// row as the sender's media placeholder. Public notes retain their
|
||||
// live-bubble ID because public transfers have no private receipts.
|
||||
let replacementID = finished.scope == .directMessage
|
||||
? message.id
|
||||
: finished.messageID
|
||||
let replacement = BitchatMessage(
|
||||
id: finished.messageID,
|
||||
id: replacementID,
|
||||
sender: message.sender,
|
||||
content: message.content,
|
||||
timestamp: finished.messageTimestamp,
|
||||
@@ -287,7 +302,31 @@ final class ChatLiveVoiceCoordinator {
|
||||
)
|
||||
switch finished.scope {
|
||||
case .directMessage:
|
||||
// Capture read state before rekeying. The user may have read the
|
||||
// live bubble and navigated away before the finalized .m4a lands.
|
||||
let shouldSendAdoptedReadReceipt =
|
||||
context.hasSentReadReceipt(finished.messageID)
|
||||
|| context.selectedPrivateChatPeer == finished.peerID
|
||||
|
||||
// Insert first so replacing the only row in a DM never
|
||||
// transiently deletes its conversation, unread state, or current
|
||||
// selection. Then remove the receiver-local live-bubble alias.
|
||||
context.upsertPrivateMessage(replacement, in: finished.peerID)
|
||||
if replacementID != finished.messageID {
|
||||
context.removePrivateMessage(withID: finished.messageID)
|
||||
}
|
||||
// The live bubble may already have emitted a receiver-local READ
|
||||
// before the sender created its finalized media row. Re-emit once
|
||||
// for the adopted stable ID now that the file has arrived.
|
||||
if shouldSendAdoptedReadReceipt,
|
||||
context.markReadReceiptSent(replacementID) {
|
||||
let receipt = ReadReceipt(
|
||||
originalMessageID: replacementID,
|
||||
readerID: context.myPeerID,
|
||||
readerNickname: context.nickname
|
||||
)
|
||||
context.sendMeshReadReceipt(receipt, to: finished.peerID)
|
||||
}
|
||||
case .publicMesh:
|
||||
context.upsertPublicMeshMessage(replacement)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -100,9 +100,14 @@ final class ChatPeerListCoordinator: @unchecked Sendable {
|
||||
|
||||
func didUpdatePeerList(_ peers: [PeerID]) {
|
||||
Task { @MainActor [weak self] in
|
||||
self?.handlePeerListUpdate(peers)
|
||||
self?.didUpdatePeerListSynchronously(peers)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func didUpdatePeerListSynchronously(_ peers: [PeerID]) {
|
||||
handlePeerListUpdate(peers)
|
||||
}
|
||||
}
|
||||
|
||||
private extension ChatPeerListCoordinator {
|
||||
|
||||
@@ -163,21 +163,20 @@ final class ChatTransportEventCoordinator {
|
||||
}
|
||||
|
||||
func didReceiveMessage(_ message: BitchatMessage) {
|
||||
runOnMain { context in
|
||||
guard !context.isMessageBlocked(message) else { return }
|
||||
guard !message.content.trimmed.isEmpty || message.isPrivate else { return }
|
||||
|
||||
if message.isPrivate {
|
||||
context.handlePrivateMessage(message)
|
||||
} else {
|
||||
context.handlePublicMessage(message)
|
||||
}
|
||||
|
||||
context.checkForMentions(message)
|
||||
context.sendHapticFeedback(for: message)
|
||||
runOnMain { [self] context in
|
||||
handleReceivedMessage(message, in: context)
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed transport events already arrive on the main actor. Handle them
|
||||
/// synchronously so observers see the ConversationStore mutation before
|
||||
/// the transport completes delivery.
|
||||
@MainActor
|
||||
@discardableResult
|
||||
func didReceiveMessageSynchronously(_ message: BitchatMessage) -> Bool {
|
||||
handleReceivedMessage(message, in: context)
|
||||
}
|
||||
|
||||
func didReceivePublicMessage(
|
||||
from peerID: PeerID,
|
||||
nickname: String,
|
||||
@@ -185,28 +184,36 @@ final class ChatTransportEventCoordinator {
|
||||
timestamp: Date,
|
||||
messageID: String?
|
||||
) {
|
||||
runOnMain { context in
|
||||
let normalized = content.trimmed
|
||||
let mentions = context.parseMentions(from: normalized)
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: nickname,
|
||||
content: normalized,
|
||||
runOnMain { [self] context in
|
||||
handlePublicMessage(
|
||||
from: peerID,
|
||||
nickname: nickname,
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: peerID,
|
||||
mentions: mentions.isEmpty ? nil : mentions
|
||||
messageID: messageID,
|
||||
in: context
|
||||
)
|
||||
|
||||
context.handlePublicMessage(message)
|
||||
context.checkForMentions(message)
|
||||
context.sendHapticFeedback(for: message)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func didReceivePublicMessageSynchronously(
|
||||
from peerID: PeerID,
|
||||
nickname: String,
|
||||
content: String,
|
||||
timestamp: Date,
|
||||
messageID: String?
|
||||
) {
|
||||
handlePublicMessage(
|
||||
from: peerID,
|
||||
nickname: nickname,
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
messageID: messageID,
|
||||
in: context
|
||||
)
|
||||
}
|
||||
|
||||
func didReceiveNoisePayload(
|
||||
from peerID: PeerID,
|
||||
type: NoisePayloadType,
|
||||
@@ -224,59 +231,134 @@ final class ChatTransportEventCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func didReceiveNoisePayloadSynchronously(
|
||||
from peerID: PeerID,
|
||||
type: NoisePayloadType,
|
||||
payload: Data,
|
||||
timestamp: Date
|
||||
) {
|
||||
handleNoisePayload(
|
||||
from: peerID,
|
||||
type: type,
|
||||
payload: payload,
|
||||
timestamp: timestamp,
|
||||
in: context
|
||||
)
|
||||
}
|
||||
|
||||
func didConnectToPeer(_ peerID: PeerID) {
|
||||
SecureLogger.debug("🤝 Peer connected: \(peerID)", category: .session)
|
||||
|
||||
runOnMain { context in
|
||||
context.isConnected = true
|
||||
context.registerEphemeralSession(peerID: peerID)
|
||||
context.notifyUIChanged()
|
||||
|
||||
if let peer = context.unifiedPeer(for: peerID) {
|
||||
let stablePeerID = PeerID(hexData: peer.noisePublicKey)
|
||||
context.cacheStablePeerID(stablePeerID, for: peerID)
|
||||
}
|
||||
|
||||
context.flushRouterOutbox(for: peerID)
|
||||
context.retryCourierDeposits(via: peerID)
|
||||
runOnMain { [weak self] _ in
|
||||
self?.didConnectToPeerSynchronously(peerID)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func didConnectToPeerSynchronously(_ peerID: PeerID) {
|
||||
SecureLogger.debug("🤝 Peer connected: \(peerID)", category: .session)
|
||||
|
||||
context.isConnected = true
|
||||
context.registerEphemeralSession(peerID: peerID)
|
||||
context.notifyUIChanged()
|
||||
|
||||
if let peer = context.unifiedPeer(for: peerID) {
|
||||
let stablePeerID = PeerID(hexData: peer.noisePublicKey)
|
||||
context.cacheStablePeerID(stablePeerID, for: peerID)
|
||||
}
|
||||
|
||||
context.flushRouterOutbox(for: peerID)
|
||||
context.retryCourierDeposits(via: peerID)
|
||||
}
|
||||
|
||||
func didDisconnectFromPeer(_ peerID: PeerID) {
|
||||
runOnMain { [weak self] _ in
|
||||
self?.didDisconnectFromPeerSynchronously(peerID)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func didDisconnectFromPeerSynchronously(_ peerID: PeerID) {
|
||||
SecureLogger.debug("👋 Peer disconnected: \(peerID)", category: .session)
|
||||
|
||||
runOnMain { context in
|
||||
context.removeEphemeralSession(peerID: peerID)
|
||||
context.removeEphemeralSession(peerID: peerID)
|
||||
|
||||
var stablePeerID = context.cachedStablePeerID(for: peerID)
|
||||
if stablePeerID == nil,
|
||||
let key = context.noiseSessionPublicKeyData(for: peerID) {
|
||||
let derivedPeerID = PeerID(hexData: key)
|
||||
context.cacheStablePeerID(derivedPeerID, for: peerID)
|
||||
stablePeerID = derivedPeerID
|
||||
}
|
||||
|
||||
if let currentPeerID = context.selectedPrivateChatPeer,
|
||||
currentPeerID == peerID,
|
||||
let stablePeerID {
|
||||
self.migrateSelectedConversationIfNeeded(
|
||||
from: peerID,
|
||||
to: stablePeerID,
|
||||
in: context
|
||||
)
|
||||
}
|
||||
|
||||
let receiptIDs = context.privateMessages(for: peerID)
|
||||
.filter { $0.senderPeerID == peerID }
|
||||
.map(\.id)
|
||||
context.unmarkReadReceiptsSent(receiptIDs)
|
||||
|
||||
context.notifyUIChanged()
|
||||
var stablePeerID = context.cachedStablePeerID(for: peerID)
|
||||
if stablePeerID == nil,
|
||||
let key = context.noiseSessionPublicKeyData(for: peerID) {
|
||||
let derivedPeerID = PeerID(hexData: key)
|
||||
context.cacheStablePeerID(derivedPeerID, for: peerID)
|
||||
stablePeerID = derivedPeerID
|
||||
}
|
||||
|
||||
if let currentPeerID = context.selectedPrivateChatPeer,
|
||||
currentPeerID == peerID,
|
||||
let stablePeerID {
|
||||
migrateSelectedConversationIfNeeded(
|
||||
from: peerID,
|
||||
to: stablePeerID,
|
||||
in: context
|
||||
)
|
||||
}
|
||||
|
||||
let receiptIDs = context.privateMessages(for: peerID)
|
||||
.filter { $0.senderPeerID == peerID }
|
||||
.map(\.id)
|
||||
context.unmarkReadReceiptsSent(receiptIDs)
|
||||
|
||||
context.notifyUIChanged()
|
||||
}
|
||||
}
|
||||
|
||||
private extension ChatTransportEventCoordinator {
|
||||
@MainActor
|
||||
func handlePublicMessage(
|
||||
from peerID: PeerID,
|
||||
nickname: String,
|
||||
content: String,
|
||||
timestamp: Date,
|
||||
messageID: String?,
|
||||
in context: any ChatTransportEventContext
|
||||
) {
|
||||
let normalized = content.trimmed
|
||||
let mentions = context.parseMentions(from: normalized)
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: nickname,
|
||||
content: normalized,
|
||||
timestamp: timestamp,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: peerID,
|
||||
mentions: mentions.isEmpty ? nil : mentions
|
||||
)
|
||||
|
||||
context.handlePublicMessage(message)
|
||||
context.checkForMentions(message)
|
||||
context.sendHapticFeedback(for: message)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@discardableResult
|
||||
func handleReceivedMessage(
|
||||
_ message: BitchatMessage,
|
||||
in context: any ChatTransportEventContext
|
||||
) -> Bool {
|
||||
guard !context.isMessageBlocked(message) else { return false }
|
||||
guard !message.content.trimmed.isEmpty || message.isPrivate else { return false }
|
||||
|
||||
if message.isPrivate {
|
||||
context.handlePrivateMessage(message)
|
||||
} else {
|
||||
context.handlePublicMessage(message)
|
||||
}
|
||||
|
||||
context.checkForMentions(message)
|
||||
context.sendHapticFeedback(for: message)
|
||||
return true
|
||||
}
|
||||
|
||||
func runOnMain(_ action: @escaping @MainActor (any ChatTransportEventContext) -> Void) {
|
||||
Task { @MainActor [weak context = self.context] in
|
||||
guard let context else { return }
|
||||
|
||||
@@ -58,6 +58,7 @@ protocol ChatVerificationContext: AnyObject {
|
||||
func noiseStaticPublicKeyData() -> Data
|
||||
func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool
|
||||
func triggerHandshake(with peerID: PeerID)
|
||||
func privateMediaPeerDidAuthenticate(_ peerID: PeerID)
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||
|
||||
@@ -116,6 +117,10 @@ extension ChatViewModel: ChatVerificationContext {
|
||||
meshService.noiseStaticPublicKeyData()
|
||||
}
|
||||
|
||||
func privateMediaPeerDidAuthenticate(_ peerID: PeerID) {
|
||||
mediaTransferCoordinator.peerDidAuthenticate(peerID.toShort())
|
||||
}
|
||||
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
|
||||
meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA)
|
||||
}
|
||||
@@ -129,6 +134,10 @@ extension ChatViewModel: ChatVerificationContext {
|
||||
}
|
||||
}
|
||||
|
||||
extension ChatVerificationContext {
|
||||
func privateMediaPeerDidAuthenticate(_ peerID: PeerID) {}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ChatVerificationCoordinator {
|
||||
struct PendingVerification {
|
||||
@@ -197,6 +206,7 @@ final class ChatVerificationCoordinator {
|
||||
guard let self else { return }
|
||||
|
||||
SecureLogger.debug("🔐 Authenticated: \(peerID)", category: .security)
|
||||
self.context.privateMediaPeerDidAuthenticate(peerID)
|
||||
|
||||
if self.context.isVerifiedFingerprint(fingerprint) {
|
||||
self.context.setEncryptionStatus(.noiseVerified, for: peerID)
|
||||
|
||||
@@ -112,7 +112,7 @@ struct PanicNetworkLifecycle {
|
||||
/// Manages the application state and business logic for BitChat.
|
||||
/// Acts as the primary coordinator between UI components and backend services,
|
||||
/// implementing the BitchatDelegate protocol to handle network events.
|
||||
final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDelegate, CommandContextProvider, GeohashParticipantContext, MessageFormattingContext {
|
||||
final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessageTransportEventDelegate, CommandContextProvider, GeohashParticipantContext, MessageFormattingContext {
|
||||
// Use MessageFormattingEngine.Patterns for regex matching (shared, precompiled)
|
||||
typealias Patterns = MessageFormattingEngine.Patterns
|
||||
|
||||
@@ -495,6 +495,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a read receipt has already been recorded for `messageID`.
|
||||
@MainActor
|
||||
func hasSentReadReceipt(_ messageID: String) -> Bool {
|
||||
sentReadReceipts.contains(messageID)
|
||||
}
|
||||
|
||||
/// Records that a read receipt is being sent for `messageID`.
|
||||
/// Returns `false` when one was already recorded — the caller must skip sending.
|
||||
@MainActor
|
||||
@@ -1701,7 +1707,82 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
|
||||
@MainActor
|
||||
func didReceiveTransportEvent(_ event: TransportEvent) {
|
||||
receiveTransportEvent(event)
|
||||
switch event {
|
||||
case .messageReceived(let message):
|
||||
_ = didReceiveTransportMessageSynchronously(message)
|
||||
|
||||
case let .publicMessageReceived(
|
||||
peerID,
|
||||
nickname,
|
||||
content,
|
||||
timestamp,
|
||||
messageID
|
||||
):
|
||||
transportEventCoordinator.didReceivePublicMessageSynchronously(
|
||||
from: peerID,
|
||||
nickname: nickname,
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
messageID: messageID
|
||||
)
|
||||
|
||||
case let .noisePayloadReceived(peerID, type, payload, timestamp):
|
||||
transportEventCoordinator.didReceiveNoisePayloadSynchronously(
|
||||
from: peerID,
|
||||
type: type,
|
||||
payload: payload,
|
||||
timestamp: timestamp
|
||||
)
|
||||
|
||||
case let .groupMessageReceived(payload, timestamp):
|
||||
groupCoordinator.handleGroupMessagePayload(
|
||||
payload,
|
||||
timestamp: timestamp
|
||||
)
|
||||
|
||||
case let .publicVoiceFrameReceived(
|
||||
peerID,
|
||||
nickname,
|
||||
payload,
|
||||
timestamp
|
||||
):
|
||||
liveVoiceCoordinator.handlePublicVoiceFramePayload(
|
||||
from: peerID,
|
||||
nickname: nickname,
|
||||
payload: payload,
|
||||
timestamp: timestamp
|
||||
)
|
||||
|
||||
case .peerConnected(let peerID):
|
||||
transportEventCoordinator.didConnectToPeerSynchronously(peerID)
|
||||
mediaTransferCoordinator.peerDidReconnect(peerID)
|
||||
|
||||
case .peerDisconnected(let peerID):
|
||||
transportEventCoordinator.didDisconnectFromPeerSynchronously(peerID)
|
||||
|
||||
case .peerListUpdated(let peers):
|
||||
peerListCoordinator.didUpdatePeerListSynchronously(peers)
|
||||
// A peer-list update follows every verified announce, which is
|
||||
// where a peer's `.vouch` capability actually arrives.
|
||||
vouchCoordinator.peersUpdated(peers)
|
||||
|
||||
case .peerSnapshotsUpdated:
|
||||
break
|
||||
|
||||
case let .messageDeliveryStatusUpdated(messageID, status):
|
||||
deliveryCoordinator.didUpdateMessageDeliveryStatus(
|
||||
messageID,
|
||||
status: status
|
||||
)
|
||||
|
||||
case .bluetoothStateUpdated(let state):
|
||||
updateBluetoothState(state)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func didReceiveTransportMessageSynchronously(_ message: BitchatMessage) -> Bool {
|
||||
transportEventCoordinator.didReceiveMessageSynchronously(message)
|
||||
}
|
||||
|
||||
func didReceiveMessage(_ message: BitchatMessage) {
|
||||
@@ -1764,6 +1845,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
|
||||
func didConnectToPeer(_ peerID: PeerID) {
|
||||
transportEventCoordinator.didConnectToPeer(peerID)
|
||||
Task { @MainActor [weak self] in
|
||||
self?.mediaTransferCoordinator.peerDidReconnect(peerID)
|
||||
}
|
||||
}
|
||||
|
||||
func didDisconnectFromPeer(_ peerID: PeerID) {
|
||||
|
||||
@@ -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) }
|
||||
@@ -537,20 +549,26 @@ struct BLEServiceCoreTests {
|
||||
|
||||
// Preserve a working victim session while an unauthenticated
|
||||
// replacement candidate arrives on a newly bound physical link.
|
||||
let message1 = try ble._test_noiseInitiateHandshake(with: victimPeerID)
|
||||
// Establish BLE as responder so the replacement candidate below is
|
||||
// not coalesced by the initiator-completion grace path.
|
||||
let message1 = try victim.initiateHandshake(with: ble.myPeerID)
|
||||
let message2 = try #require(
|
||||
try victim.processHandshakeMessage(from: ble.myPeerID, message: message1)
|
||||
)
|
||||
let message3 = try #require(
|
||||
try ble._test_noiseProcessHandshakeMessage(
|
||||
from: victimPeerID,
|
||||
message: message1
|
||||
)
|
||||
)
|
||||
let message3 = try #require(
|
||||
try victim.processHandshakeMessage(
|
||||
from: ble.myPeerID,
|
||||
message: message2
|
||||
)
|
||||
)
|
||||
_ = try victim.processHandshakeMessage(
|
||||
from: ble.myPeerID,
|
||||
_ = try ble._test_noiseProcessHandshakeMessage(
|
||||
from: victimPeerID,
|
||||
message: message3
|
||||
)
|
||||
await ble._test_drainNoiseMessagePipeline()
|
||||
#expect(ble.canDeliverSecurely(to: victimPeerID))
|
||||
|
||||
let centralUUID = "central-replacement-xx-message-one"
|
||||
@@ -614,7 +632,168 @@ struct BLEServiceCoreTests {
|
||||
for: victimPeerID
|
||||
)
|
||||
)
|
||||
#expect(ble.canDeliverSecurely(to: victimPeerID))
|
||||
// Ordinary reconnect hardening quarantines the cached transport while
|
||||
// this candidate proves the claimed identity. It must be unavailable
|
||||
// for sending as well as unable to authenticate this ingress link.
|
||||
#expect(!ble.canDeliverSecurely(to: victimPeerID))
|
||||
}
|
||||
|
||||
@Test
|
||||
func failedInboundReconnectRestoresAndDrainsWaitingWorkOnce() async throws {
|
||||
let ble = makeService()
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||
|
||||
// Establish BLE as responder so the following inbound reconnect is
|
||||
// not intentionally coalesced by the initiator-completion grace path.
|
||||
let message1 = try alice.initiateHandshake(with: ble.myPeerID)
|
||||
let message2 = try #require(
|
||||
try ble._test_noiseProcessHandshakeMessage(
|
||||
from: alicePeerID,
|
||||
message: message1
|
||||
)
|
||||
)
|
||||
let message3 = try #require(
|
||||
try alice.processHandshakeMessage(
|
||||
from: ble.myPeerID,
|
||||
message: message2
|
||||
)
|
||||
)
|
||||
_ = try ble._test_noiseProcessHandshakeMessage(
|
||||
from: alicePeerID,
|
||||
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
|
||||
&& PeerID(hexData: $0.senderID) == ble.myPeerID
|
||||
&& $0.payload.count
|
||||
!= NoiseSecurityConstants.xxInitialMessageSize
|
||||
}
|
||||
},
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
try #require(responseReady)
|
||||
let forgedMessage2 = try #require(
|
||||
outbound.snapshot().first {
|
||||
$0.type == MessageType.noiseHandshake.rawValue
|
||||
&& PeerID(hexData: $0.senderID) == ble.myPeerID
|
||||
&& $0.payload.count
|
||||
!= NoiseSecurityConstants.xxInitialMessageSize
|
||||
}?.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 forgedEarlyPayload = try #require(
|
||||
BLENoisePayloadFactory.privateMessage(
|
||||
content: "forged early message",
|
||||
messageID: "forged-early"
|
||||
)
|
||||
)
|
||||
try #require(
|
||||
mallory.hasEstablishedSession(with: ble.myPeerID),
|
||||
"forged initiator did not establish after producing message three"
|
||||
)
|
||||
let forgedEarlyCiphertext = try mallory.encrypt(
|
||||
forgedEarlyPayload,
|
||||
for: ble.myPeerID
|
||||
)
|
||||
let earlyPacket = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: alicePeerID.id) ?? Data(),
|
||||
recipientID: Data(hexString: ble.myPeerID.id),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1_000) + 1,
|
||||
payload: forgedEarlyCiphertext,
|
||||
signature: nil,
|
||||
ttl: 7
|
||||
)
|
||||
ble._test_handlePacket(earlyPacket, fromPeerID: alicePeerID)
|
||||
await ble._test_drainNoiseMessagePipeline()
|
||||
|
||||
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) + 2,
|
||||
payload: forgedMessage3,
|
||||
signature: nil,
|
||||
ttl: 7
|
||||
)
|
||||
ble._test_handlePacket(thirdPacket, fromPeerID: alicePeerID)
|
||||
|
||||
// Rollback restores the same generation. It retries the bounded early
|
||||
// ciphertext and drains both outbound queues, but must not repeat a
|
||||
// new-generation capability proof or forced announce.
|
||||
let drained = await TestHelpers.waitUntil(
|
||||
{ outbound.count(ofType: .noiseEncrypted) >= 2 },
|
||||
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 == 2)
|
||||
#expect(
|
||||
plaintexts.filter {
|
||||
$0.first == NoisePayloadType.authenticatedPeerState.rawValue
|
||||
}.isEmpty
|
||||
)
|
||||
#expect(
|
||||
plaintexts.filter {
|
||||
$0.first == NoisePayloadType.privateMessage.rawValue
|
||||
}.count == 1
|
||||
)
|
||||
#expect(
|
||||
plaintexts.filter {
|
||||
$0.first == NoisePayloadType.groupInvite.rawValue
|
||||
}.count == 1
|
||||
)
|
||||
#expect(outbound.count(ofType: .announce) == 0)
|
||||
|
||||
// A duplicate ready callback cannot replay either buffer.
|
||||
ble._test_reconcileCurrentNoiseSession(for: alicePeerID)
|
||||
await ble._test_drainNoiseMessagePipeline()
|
||||
#expect(outbound.count(ofType: .noiseEncrypted) == 2)
|
||||
#expect(outbound.count(ofType: .announce) == 0)
|
||||
}
|
||||
|
||||
/// A legitimate rotation announce necessarily arrives on a link still
|
||||
@@ -943,6 +1122,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 {
|
||||
@@ -1076,6 +1289,7 @@ private final class PublicCaptureDelegate: BitchatDelegate {
|
||||
defer { lock.unlock() }
|
||||
return publicMessages
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -14,6 +14,7 @@ import BitFoundation
|
||||
@MainActor
|
||||
private final class MockChatLiveVoiceContext: ChatLiveVoiceContext {
|
||||
var nickname = "me"
|
||||
var myPeerID = PeerID(str: "0102030405060708")
|
||||
var selectedPrivateChatPeer: PeerID?
|
||||
var isViewingPublicMeshTimeline = false
|
||||
var blockedPeers: Set<PeerID> = []
|
||||
@@ -23,7 +24,10 @@ private final class MockChatLiveVoiceContext: ChatLiveVoiceContext {
|
||||
private(set) var upsertedMessages: [(message: BitchatMessage, peerID: PeerID)] = []
|
||||
private(set) var upsertedPublicMessages: [BitchatMessage] = []
|
||||
private(set) var removedMessageIDs: [String] = []
|
||||
private(set) var sentReadReceipts: [(receipt: ReadReceipt, peerID: PeerID)] = []
|
||||
private(set) var talkerUpdates: [String?] = []
|
||||
private(set) var privateMutationLog: [String] = []
|
||||
private var readReceiptMessageIDs: Set<String> = []
|
||||
|
||||
func isPeerBlocked(_ peerID: PeerID) -> Bool { blockedPeers.contains(peerID) }
|
||||
func resolveNickname(for peerID: PeerID) -> String { "alice" }
|
||||
@@ -31,6 +35,7 @@ private final class MockChatLiveVoiceContext: ChatLiveVoiceContext {
|
||||
func appendPublicMeshMessage(_ message: BitchatMessage) { appendedPublicMessages.append(message) }
|
||||
func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID) {
|
||||
upsertedMessages.append((message, peerID))
|
||||
privateMutationLog.append("upsert:\(message.id)")
|
||||
}
|
||||
func upsertPublicMeshMessage(_ message: BitchatMessage) {
|
||||
upsertedPublicMessages.append(message)
|
||||
@@ -38,8 +43,18 @@ private final class MockChatLiveVoiceContext: ChatLiveVoiceContext {
|
||||
@discardableResult
|
||||
func removePrivateMessage(withID messageID: String) -> BitchatMessage? {
|
||||
removedMessageIDs.append(messageID)
|
||||
privateMutationLog.append("remove:\(messageID)")
|
||||
return nil
|
||||
}
|
||||
func hasSentReadReceipt(_ messageID: String) -> Bool {
|
||||
readReceiptMessageIDs.contains(messageID)
|
||||
}
|
||||
func markReadReceiptSent(_ messageID: String) -> Bool {
|
||||
readReceiptMessageIDs.insert(messageID).inserted
|
||||
}
|
||||
func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||
sentReadReceipts.append((receipt, peerID))
|
||||
}
|
||||
func removeMessage(withID messageID: String, cleanupFile: Bool) {
|
||||
removedMessageIDs.append(messageID)
|
||||
}
|
||||
@@ -151,17 +166,29 @@ struct ChatLiveVoiceCoordinatorTests {
|
||||
|
||||
@Test func absorbsFinalizedNoteIntoLiveBubble() throws {
|
||||
let context = MockChatLiveVoiceContext()
|
||||
context.selectedPrivateChatPeer = peer
|
||||
let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false)
|
||||
let burstID = makeBurstID(0xB2)
|
||||
let hex = burstID.hexEncodedString()
|
||||
let fileName = "voice_\(hex).m4a"
|
||||
let stableMessageID = try #require(PrivateMediaMessageIdentity.stableID(
|
||||
senderPeerID: peer,
|
||||
recipientPeerID: context.myPeerID,
|
||||
fileName: fileName
|
||||
))
|
||||
|
||||
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 7, count: 50)]))), to: coordinator, from: peer)
|
||||
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))), to: coordinator, from: peer)
|
||||
let bubble = try #require(context.handledPrivateMessages.first)
|
||||
// The user read the live bubble, then left before the finalized file
|
||||
// arrived. Stable-ID adoption must preserve that read state.
|
||||
#expect(context.markReadReceiptSent(bubble.id))
|
||||
context.selectedPrivateChatPeer = nil
|
||||
|
||||
let note = BitchatMessage(
|
||||
id: stableMessageID,
|
||||
sender: "alice",
|
||||
content: "[voice] voice_\(hex).m4a",
|
||||
content: "[voice] \(fileName)",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
@@ -170,12 +197,21 @@ struct ChatLiveVoiceCoordinatorTests {
|
||||
)
|
||||
#expect(coordinator.absorbFinalizedVoiceNote(note))
|
||||
|
||||
// The note replaced the live bubble in place: same message ID, new
|
||||
// content, partial capture deleted.
|
||||
// The finalized note adopts the sender-correlatable ID, removes the
|
||||
// receiver-local live ID, and emits a fresh READ now that the sender
|
||||
// has created its finalized media row.
|
||||
let replacement = try #require(context.upsertedMessages.last)
|
||||
#expect(replacement.message.id == bubble.id)
|
||||
#expect(replacement.message.id == stableMessageID)
|
||||
#expect(replacement.message.content == note.content)
|
||||
#expect(replacement.peerID == peer)
|
||||
#expect(context.removedMessageIDs.contains(bubble.id))
|
||||
#expect(Array(context.privateMutationLog.suffix(2)) == [
|
||||
"upsert:\(stableMessageID)",
|
||||
"remove:\(bubble.id)"
|
||||
])
|
||||
#expect(context.sentReadReceipts.count == 1)
|
||||
#expect(context.sentReadReceipts.first?.receipt.originalMessageID == stableMessageID)
|
||||
#expect(context.sentReadReceipts.first?.peerID == peer)
|
||||
// The promoted partial capture is deleted in favor of the note.
|
||||
let url = try #require(fallbackFileURL(burstID: burstID, peerID: peer))
|
||||
#expect(!FileManager.default.fileExists(atPath: url.path))
|
||||
@@ -449,7 +485,8 @@ struct ChatLiveVoiceCoordinatorTests {
|
||||
isRelay: false, isPrivate: true, recipientNickname: "me", senderPeerID: peer
|
||||
)
|
||||
#expect(coordinator.absorbFinalizedVoiceNote(dmNote))
|
||||
#expect(try #require(context.upsertedMessages.last).message.id == dmBubble.id)
|
||||
#expect(try #require(context.upsertedMessages.last).message.id == dmNote.id)
|
||||
#expect(context.removedMessageIDs.contains(dmBubble.id))
|
||||
}
|
||||
|
||||
@Test func finalizedNoteBindsToItsAuthenticatedSender() throws {
|
||||
@@ -479,8 +516,9 @@ struct ChatLiveVoiceCoordinatorTests {
|
||||
)
|
||||
#expect(coordinator.absorbFinalizedVoiceNote(note))
|
||||
let replacement = try #require(context.upsertedMessages.last)
|
||||
#expect(replacement.message.id == victimBubble.id)
|
||||
#expect(replacement.message.id == note.id)
|
||||
#expect(replacement.peerID == peer)
|
||||
#expect(context.removedMessageIDs.contains(victimBubble.id))
|
||||
|
||||
// The attacker's note can only ever claim the attacker's own bubble.
|
||||
let attackerNote = BitchatMessage(
|
||||
@@ -489,8 +527,9 @@ struct ChatLiveVoiceCoordinatorTests {
|
||||
)
|
||||
#expect(coordinator.absorbFinalizedVoiceNote(attackerNote))
|
||||
let attackerReplacement = try #require(context.upsertedMessages.last)
|
||||
#expect(attackerReplacement.message.id == attackerBubble.id)
|
||||
#expect(attackerReplacement.message.id == attackerNote.id)
|
||||
#expect(attackerReplacement.peerID == attacker)
|
||||
#expect(context.removedMessageIDs.contains(attackerBubble.id))
|
||||
|
||||
// Both registry entries are consumed — nothing left to hijack.
|
||||
#expect(!coordinator.absorbFinalizedVoiceNote(note))
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
|
||||
//
|
||||
// Real file/codec work remains covered by `ChatMediaPreparationTests`. These
|
||||
// tests inject a paused voice-note preparer to exercise cancellation ownership
|
||||
// tests inject paused media preparers to exercise cancellation ownership
|
||||
// across the detached-preparation/MainActor boundary deterministically.
|
||||
//
|
||||
|
||||
@@ -89,12 +89,26 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
||||
}
|
||||
|
||||
// Mesh file transfer
|
||||
private(set) var privateFileSends: [(peerID: PeerID, transferId: String)] = []
|
||||
private(set) var privateFileSends: [(
|
||||
packet: BitchatFilePacket,
|
||||
peerID: PeerID,
|
||||
transferId: String
|
||||
)] = []
|
||||
private(set) var privateFileLegacyAllowances: [Bool] = []
|
||||
private(set) var privateFileReceiptRetryTransferIDs: [String] = []
|
||||
private(set) var broadcastFileSends: [String] = []
|
||||
private(set) var cancelledTransfers: [String] = []
|
||||
private(set) var privateMediaPolicyResolutionRequests: [PeerID] = []
|
||||
var privateMediaPolicy: PrivateMediaSendPolicy = .encrypted
|
||||
var resolvedPrivateMediaPolicy: PrivateMediaSendPolicy?
|
||||
var resolvesPrivateMediaPolicyImmediately = true
|
||||
var supportsAuthenticatedPrivateMediaReceipts = false
|
||||
var authenticatedPrivateMediaReceiptGeneration = UUID(
|
||||
uuidString: "00000000-0000-0000-0000-000000000001"
|
||||
)!
|
||||
private var pendingPrivateMediaPolicyResolutions: [
|
||||
@MainActor (PrivateMediaSendPolicy) -> Void
|
||||
] = []
|
||||
private(set) var legacyConsentRequests: [(
|
||||
id: UUID,
|
||||
peerID: PeerID,
|
||||
@@ -109,11 +123,40 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
||||
privateMediaPolicy
|
||||
}
|
||||
|
||||
func authenticatedPrivateMediaReceiptSessionGeneration(
|
||||
to peerID: PeerID
|
||||
) -> UUID? {
|
||||
supportsAuthenticatedPrivateMediaReceipts
|
||||
? authenticatedPrivateMediaReceiptGeneration
|
||||
: nil
|
||||
}
|
||||
|
||||
func resolvePrivateMediaSendPolicy(
|
||||
to peerID: PeerID,
|
||||
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||
) {
|
||||
completion(resolvedPrivateMediaPolicy ?? privateMediaPolicy)
|
||||
privateMediaPolicyResolutionRequests.append(peerID)
|
||||
if resolvesPrivateMediaPolicyImmediately {
|
||||
completion(resolvedPrivateMediaPolicy ?? privateMediaPolicy)
|
||||
} else {
|
||||
pendingPrivateMediaPolicyResolutions.append(completion)
|
||||
}
|
||||
}
|
||||
|
||||
var pendingPrivateMediaPolicyResolutionCount: Int {
|
||||
pendingPrivateMediaPolicyResolutions.count
|
||||
}
|
||||
|
||||
func resolveNextPrivateMediaPolicy(
|
||||
_ policy: PrivateMediaSendPolicy? = nil
|
||||
) {
|
||||
guard !pendingPrivateMediaPolicyResolutions.isEmpty else { return }
|
||||
let completion = pendingPrivateMediaPolicyResolutions.removeFirst()
|
||||
completion(
|
||||
policy
|
||||
?? resolvedPrivateMediaPolicy
|
||||
?? privateMediaPolicy
|
||||
)
|
||||
}
|
||||
|
||||
func requestLegacyPrivateMediaConsent(
|
||||
@@ -154,10 +197,20 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
||||
transferId: String,
|
||||
allowLegacyFallback: Bool
|
||||
) {
|
||||
privateFileSends.append((peerID, transferId))
|
||||
privateFileSends.append((packet, peerID, transferId))
|
||||
privateFileLegacyAllowances.append(allowLegacyFallback)
|
||||
}
|
||||
|
||||
func sendFilePrivateReceiptRetry(
|
||||
_ packet: BitchatFilePacket,
|
||||
to peerID: PeerID,
|
||||
transferId: String
|
||||
) {
|
||||
privateFileSends.append((packet, peerID, transferId))
|
||||
privateFileLegacyAllowances.append(false)
|
||||
privateFileReceiptRetryTransferIDs.append(transferId)
|
||||
}
|
||||
|
||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
|
||||
broadcastFileSends.append(transferId)
|
||||
}
|
||||
@@ -172,19 +225,8 @@ private final class PausedVoiceNotePreparer: @unchecked Sendable {
|
||||
private var started = false
|
||||
private var released = false
|
||||
private var finished = false
|
||||
private let packet: BitchatFilePacket
|
||||
|
||||
init() {
|
||||
let content = Data("voice".utf8)
|
||||
packet = BitchatFilePacket(
|
||||
fileName: "paused.m4a",
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "audio/mp4",
|
||||
content: content
|
||||
)
|
||||
}
|
||||
|
||||
func prepare(_: URL) throws -> BitchatFilePacket {
|
||||
func prepare(_ url: URL) throws -> BitchatFilePacket {
|
||||
condition.lock()
|
||||
started = true
|
||||
condition.broadcast()
|
||||
@@ -194,7 +236,13 @@ private final class PausedVoiceNotePreparer: @unchecked Sendable {
|
||||
finished = true
|
||||
condition.broadcast()
|
||||
condition.unlock()
|
||||
return packet
|
||||
let content = Data("voice".utf8)
|
||||
return BitchatFilePacket(
|
||||
fileName: url.lastPathComponent,
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "audio/mp4",
|
||||
content: content
|
||||
)
|
||||
}
|
||||
|
||||
var hasStarted: Bool {
|
||||
@@ -217,6 +265,59 @@ private final class PausedVoiceNotePreparer: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
private final class StaticVoiceNotePreparer: @unchecked Sendable {
|
||||
private let packet: BitchatFilePacket
|
||||
|
||||
init(fileName: String, content: Data = Data("voice".utf8)) {
|
||||
packet = BitchatFilePacket(
|
||||
fileName: fileName,
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "audio/mp4",
|
||||
content: content
|
||||
)
|
||||
}
|
||||
|
||||
func prepare(_ _: URL) throws -> BitchatFilePacket {
|
||||
packet
|
||||
}
|
||||
}
|
||||
|
||||
private final class DeterministicMediaTransferIDFactory:
|
||||
@unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var nextOrdinal = 0
|
||||
|
||||
func make(messageID: String) -> String {
|
||||
lock.lock()
|
||||
defer {
|
||||
nextOrdinal += 1
|
||||
lock.unlock()
|
||||
}
|
||||
return "\(messageID)-attempt-\(nextOrdinal)"
|
||||
}
|
||||
}
|
||||
|
||||
private final class MutableMediaRetryClock: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var value: Date
|
||||
|
||||
init(_ value: Date) {
|
||||
self.value = value
|
||||
}
|
||||
|
||||
func now() -> Date {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return value
|
||||
}
|
||||
|
||||
func advance(by interval: TimeInterval) {
|
||||
lock.lock()
|
||||
value = value.addingTimeInterval(interval)
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Coordinator Tests Against Mock Context
|
||||
|
||||
/// Exercises `ChatMediaTransferCoordinator` against
|
||||
@@ -452,6 +553,109 @@ struct ChatMediaTransferCoordinatorContextTests {
|
||||
#expect(coordinator.transferIdToMessageIDs.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func privateVoiceNoteUsesWireDerivableMessageID() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("voice_receipt_\(UUID().uuidString).m4a")
|
||||
try Data("voice".utf8).write(to: url)
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ context.privateFileSends.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
let message = try #require(context.privateChats[peerID]?.first)
|
||||
let sentPacket = try #require(context.privateFileSends.first?.packet)
|
||||
#expect(message.id == PrivateMediaMessageIdentity.stableID(
|
||||
for: sentPacket,
|
||||
senderPeerID: context.myPeerID,
|
||||
recipientPeerID: peerID
|
||||
))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func privateImageUsesWireDerivableMessageID() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||
let peerID = PeerID(str: "99aabbccddeeff00")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
let sourceURL = try makeCoordinatorTestImageURL()
|
||||
defer { try? FileManager.default.removeItem(at: sourceURL) }
|
||||
|
||||
coordinator.sendImage(from: sourceURL)
|
||||
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ context.privateFileSends.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
let message = try #require(context.privateChats[peerID]?.first)
|
||||
let sentPacket = try #require(context.privateFileSends.first?.packet)
|
||||
#expect(message.id == PrivateMediaMessageIdentity.stableID(
|
||||
for: sentPacket,
|
||||
senderPeerID: context.myPeerID,
|
||||
recipientPeerID: peerID
|
||||
))
|
||||
coordinator.cleanupLocalFile(forMessage: message)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func panicDuringImagePreparationDeletesStaleOutputWithoutSideEffects() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let peerID = PeerID(str: "99aabbccddeeff00")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
let sourceURL = try makeCoordinatorTestImageURL()
|
||||
let outputURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(
|
||||
"panic-stale-image-\(UUID().uuidString).jpg"
|
||||
)
|
||||
let preparer = PausedImagePreparer(outputURL: outputURL)
|
||||
let coordinator = ChatMediaTransferCoordinator(
|
||||
context: context,
|
||||
prepareImagePacket: { url in try preparer.prepare(url) }
|
||||
)
|
||||
defer {
|
||||
preparer.release()
|
||||
try? FileManager.default.removeItem(at: sourceURL)
|
||||
try? FileManager.default.removeItem(at: outputURL)
|
||||
}
|
||||
|
||||
coordinator.sendImage(from: sourceURL)
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ preparer.hasStarted },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).asyncAfter(
|
||||
deadline: .now() + .milliseconds(100)
|
||||
) {
|
||||
preparer.release()
|
||||
}
|
||||
coordinator.resetForPanic()
|
||||
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ preparer.hasFinished },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ !FileManager.default.fileExists(atPath: outputURL.path) },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
#expect(context.privateChats[peerID]?.isEmpty != false)
|
||||
#expect(context.appendedPublicMessages.isEmpty)
|
||||
#expect(context.privateFileSends.isEmpty)
|
||||
#expect(context.broadcastFileSends.isEmpty)
|
||||
#expect(context.systemMessages.isEmpty)
|
||||
#expect(context.deliveryStatusUpdates.isEmpty)
|
||||
#expect(coordinator.transferIdToMessageIDs.isEmpty)
|
||||
#expect(coordinator.messageIDToTransferId.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func cancelVoiceNoteDuringDetachedPreparationCannotSendOrRestoreMapping() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
@@ -696,6 +900,548 @@ struct ChatMediaTransferCoordinatorContextTests {
|
||||
#expect(context.legacyConsentRequests.isEmpty)
|
||||
#expect(context.privateFileSends.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func receiptCapableEncryptedMediaRetriesExactPacketAfterReconnect() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
context.supportsAuthenticatedPrivateMediaReceipts = true
|
||||
let fileName = "voice_0011223344556677.m4a"
|
||||
let preparer = StaticVoiceNotePreparer(fileName: fileName)
|
||||
let transferIDs = DeterministicMediaTransferIDFactory()
|
||||
let coordinator = ChatMediaTransferCoordinator(
|
||||
context: context,
|
||||
prepareVoiceNotePacket: { url in
|
||||
try preparer.prepare(url)
|
||||
},
|
||||
transferIDFactory: transferIDs.make
|
||||
)
|
||||
let url = try makeCoordinatorVoiceURL(fileName: fileName)
|
||||
defer {
|
||||
try? FileManager.default.removeItem(
|
||||
at: url.deletingLastPathComponent()
|
||||
)
|
||||
}
|
||||
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ context.privateFileSends.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
let messageID = try #require(
|
||||
context.privateChats[peerID]?.first?.id
|
||||
)
|
||||
let initial = try #require(
|
||||
context.privateFileSends.first
|
||||
)
|
||||
#expect(PrivateMediaMessageIdentity.isStableID(messageID))
|
||||
#expect(coordinator.retainedReconnectRetryCount == 1)
|
||||
|
||||
coordinator.handleTransferEvent(.completed(
|
||||
id: initial.transferId,
|
||||
totalFragments: 1
|
||||
))
|
||||
#expect(coordinator.retainedReconnectRetryCount == 1)
|
||||
#expect(context.deliveryStatusUpdates.last?.status == .sent)
|
||||
|
||||
coordinator.peerDidReconnect(peerID)
|
||||
#expect(context.privateFileSends.count == 2)
|
||||
let retry = try #require(context.privateFileSends.last)
|
||||
#expect(retry.packet.encode() == initial.packet.encode())
|
||||
#expect(retry.peerID == peerID)
|
||||
#expect(retry.transferId != initial.transferId)
|
||||
#expect(context.privateFileReceiptRetryTransferIDs == [
|
||||
retry.transferId
|
||||
])
|
||||
#expect(context.privateFileLegacyAllowances == [false, false])
|
||||
|
||||
coordinator.confirmPrivateMediaDelivery(messageID: messageID)
|
||||
#expect(coordinator.retainedReconnectRetryCount == 0)
|
||||
#expect(context.cancelledTransfers == [retry.transferId])
|
||||
#expect(context.removedMessages.isEmpty)
|
||||
#expect(!context.deliveryStatusUpdates.contains {
|
||||
if case .failed = $0.status { return true }
|
||||
return false
|
||||
})
|
||||
|
||||
// Receipt confirmation removes retry ownership before transport
|
||||
// cancellation, so its late callback cannot delete the delivered row
|
||||
// or re-arm another reconnect retry.
|
||||
coordinator.handleTransferEvent(.cancelled(
|
||||
id: retry.transferId,
|
||||
sentFragments: 1,
|
||||
totalFragments: 2
|
||||
))
|
||||
coordinator.peerDidReconnect(peerID)
|
||||
#expect(context.privateFileSends.count == 2)
|
||||
#expect(context.removedMessages.isEmpty)
|
||||
#expect(coordinator.messageIDToTransferId[messageID] == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func bit8OnlyEncryptedMediaNeverRetainsOrAutomaticallyRetries() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
context.privateMediaPolicy = .encrypted
|
||||
context.supportsAuthenticatedPrivateMediaReceipts = false
|
||||
let fileName = "voice_1111222233334444.m4a"
|
||||
let preparer = StaticVoiceNotePreparer(fileName: fileName)
|
||||
let coordinator = ChatMediaTransferCoordinator(
|
||||
context: context,
|
||||
prepareVoiceNotePacket: { url in
|
||||
try preparer.prepare(url)
|
||||
}
|
||||
)
|
||||
let url = try makeCoordinatorVoiceURL(fileName: fileName)
|
||||
defer {
|
||||
try? FileManager.default.removeItem(
|
||||
at: url.deletingLastPathComponent()
|
||||
)
|
||||
}
|
||||
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ context.privateFileSends.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
let initial = try #require(context.privateFileSends.first)
|
||||
coordinator.handleTransferEvent(.completed(
|
||||
id: initial.transferId,
|
||||
totalFragments: 1
|
||||
))
|
||||
coordinator.peerDidReconnect(peerID)
|
||||
coordinator.peerDidAuthenticate(peerID)
|
||||
|
||||
#expect(coordinator.retainedReconnectRetryCount == 0)
|
||||
#expect(context.privateFileSends.count == 1)
|
||||
#expect(context.privateFileReceiptRetryTransferIDs.isEmpty)
|
||||
#expect(context.privateMediaPolicyResolutionRequests.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func consentedRawLegacyMediaNeverEntersAutomaticRetry() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
context.privateMediaPolicy = .legacyRequiresConsent
|
||||
// Even a contradictory stale bit-9 observation must not retain an
|
||||
// invocation that actually selected the explicit raw path.
|
||||
context.supportsAuthenticatedPrivateMediaReceipts = true
|
||||
let fileName = "voice_2222333344445555.m4a"
|
||||
let preparer = StaticVoiceNotePreparer(fileName: fileName)
|
||||
let coordinator = ChatMediaTransferCoordinator(
|
||||
context: context,
|
||||
prepareVoiceNotePacket: { url in
|
||||
try preparer.prepare(url)
|
||||
}
|
||||
)
|
||||
let url = try makeCoordinatorVoiceURL(fileName: fileName)
|
||||
defer {
|
||||
try? FileManager.default.removeItem(
|
||||
at: url.deletingLastPathComponent()
|
||||
)
|
||||
}
|
||||
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ context.legacyConsentRequests.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
context.resolveNextLegacyConsent(true)
|
||||
let initial = try #require(context.privateFileSends.first)
|
||||
#expect(context.privateFileLegacyAllowances == [true])
|
||||
#expect(coordinator.retainedReconnectRetryCount == 0)
|
||||
|
||||
coordinator.handleTransferEvent(.completed(
|
||||
id: initial.transferId,
|
||||
totalFragments: 1
|
||||
))
|
||||
context.privateMediaPolicy = .encrypted
|
||||
coordinator.peerDidReconnect(peerID)
|
||||
coordinator.peerDidAuthenticate(peerID)
|
||||
|
||||
#expect(context.privateFileSends.count == 1)
|
||||
#expect(context.privateFileReceiptRetryTransferIDs.isEmpty)
|
||||
#expect(coordinator.retainedReconnectRetryCount == 0)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func authenticatedGenerationSupersedesStaleReconnectResolution() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
context.supportsAuthenticatedPrivateMediaReceipts = true
|
||||
context.resolvesPrivateMediaPolicyImmediately = false
|
||||
let oldGeneration = context
|
||||
.authenticatedPrivateMediaReceiptGeneration
|
||||
let newGeneration = UUID(
|
||||
uuidString: "00000000-0000-0000-0000-000000000002"
|
||||
)!
|
||||
let fileName = "voice_3333444455556666.m4a"
|
||||
let preparer = StaticVoiceNotePreparer(fileName: fileName)
|
||||
let transferIDs = DeterministicMediaTransferIDFactory()
|
||||
let coordinator = ChatMediaTransferCoordinator(
|
||||
context: context,
|
||||
prepareVoiceNotePacket: { url in
|
||||
try preparer.prepare(url)
|
||||
},
|
||||
transferIDFactory: transferIDs.make
|
||||
)
|
||||
let url = try makeCoordinatorVoiceURL(fileName: fileName)
|
||||
defer {
|
||||
try? FileManager.default.removeItem(
|
||||
at: url.deletingLastPathComponent()
|
||||
)
|
||||
}
|
||||
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ context.privateFileSends.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
let initial = try #require(context.privateFileSends.first)
|
||||
coordinator.peerDidReconnect(peerID)
|
||||
#expect(context.pendingPrivateMediaPolicyResolutionCount == 1)
|
||||
|
||||
context.authenticatedPrivateMediaReceiptGeneration = newGeneration
|
||||
coordinator.peerDidAuthenticate(peerID)
|
||||
#expect(context.pendingPrivateMediaPolicyResolutionCount == 2)
|
||||
|
||||
// The old-generation completion lost ownership and is inert.
|
||||
context.resolveNextPrivateMediaPolicy(.encrypted)
|
||||
#expect(context.privateFileReceiptRetryTransferIDs.isEmpty)
|
||||
#expect(context.cancelledTransfers.isEmpty)
|
||||
|
||||
context.resolveNextPrivateMediaPolicy(.encrypted)
|
||||
#expect(context.cancelledTransfers == [initial.transferId])
|
||||
#expect(context.privateFileReceiptRetryTransferIDs.count == 1)
|
||||
#expect(
|
||||
context.authenticatedPrivateMediaReceiptGeneration
|
||||
== newGeneration
|
||||
)
|
||||
#expect(oldGeneration != newGeneration)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func panicClearsRetainedRetryPendingResolutionAndExpiry() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
context.supportsAuthenticatedPrivateMediaReceipts = true
|
||||
context.resolvesPrivateMediaPolicyImmediately = false
|
||||
let fileName = "voice_3333444455556666.m4a"
|
||||
let preparer = StaticVoiceNotePreparer(fileName: fileName)
|
||||
let coordinator = ChatMediaTransferCoordinator(
|
||||
context: context,
|
||||
prepareVoiceNotePacket: { url in
|
||||
try preparer.prepare(url)
|
||||
},
|
||||
reconnectRetryLimits: PrivateMediaReconnectRetryLimits(
|
||||
maxRetainedPackets: 1,
|
||||
maxRetainedBytes: 1_024,
|
||||
maxRetriesPerMessage: 1,
|
||||
retentionSeconds: 0.1,
|
||||
maxRetriesPerReconnect: 1
|
||||
)
|
||||
)
|
||||
let url = try makeCoordinatorVoiceURL(fileName: fileName)
|
||||
defer {
|
||||
try? FileManager.default.removeItem(
|
||||
at: url.deletingLastPathComponent()
|
||||
)
|
||||
}
|
||||
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ context.privateFileSends.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
let initial = try #require(context.privateFileSends.first)
|
||||
coordinator.handleTransferEvent(.completed(
|
||||
id: initial.transferId,
|
||||
totalFragments: 1
|
||||
))
|
||||
#expect(coordinator.retainedReconnectRetryCount == 1)
|
||||
|
||||
coordinator.peerDidReconnect(peerID)
|
||||
#expect(context.pendingPrivateMediaPolicyResolutionCount == 1)
|
||||
let failedBeforePanic = context.deliveryStatusUpdates.filter {
|
||||
if case .failed = $0.status { return true }
|
||||
return false
|
||||
}.count
|
||||
|
||||
coordinator.resetForPanic()
|
||||
context.resolveNextPrivateMediaPolicy(.encrypted)
|
||||
try await Task.sleep(nanoseconds: 250_000_000)
|
||||
coordinator._test_expireReconnectRetries()
|
||||
|
||||
#expect(coordinator.retainedReconnectRetryCount == 0)
|
||||
#expect(coordinator.retainedReconnectRetryBytes == 0)
|
||||
#expect(coordinator.transferIdToMessageIDs.isEmpty)
|
||||
#expect(coordinator.messageIDToTransferId.isEmpty)
|
||||
#expect(context.privateFileSends.count == 1)
|
||||
#expect(context.privateFileReceiptRetryTransferIDs.isEmpty)
|
||||
#expect(context.deliveryStatusUpdates.filter {
|
||||
if case .failed = $0.status { return true }
|
||||
return false
|
||||
}.count == failedBeforePanic)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func retryCountAndRetentionTimeEndInVisibleFailure() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
context.supportsAuthenticatedPrivateMediaReceipts = true
|
||||
let clock = MutableMediaRetryClock(
|
||||
Date(timeIntervalSince1970: 4_000)
|
||||
)
|
||||
let limits = PrivateMediaReconnectRetryLimits(
|
||||
maxRetainedPackets: 2,
|
||||
maxRetainedBytes: 1_024,
|
||||
maxRetriesPerMessage: 1,
|
||||
retentionSeconds: 10,
|
||||
maxRetriesPerReconnect: 1
|
||||
)
|
||||
let fileName = "voice_4444555566667777.m4a"
|
||||
let preparer = StaticVoiceNotePreparer(fileName: fileName)
|
||||
let transferIDs = DeterministicMediaTransferIDFactory()
|
||||
let coordinator = ChatMediaTransferCoordinator(
|
||||
context: context,
|
||||
prepareVoiceNotePacket: { url in
|
||||
try preparer.prepare(url)
|
||||
},
|
||||
reconnectRetryLimits: limits,
|
||||
now: clock.now,
|
||||
transferIDFactory: transferIDs.make
|
||||
)
|
||||
let url = try makeCoordinatorVoiceURL(fileName: fileName)
|
||||
defer {
|
||||
try? FileManager.default.removeItem(
|
||||
at: url.deletingLastPathComponent()
|
||||
)
|
||||
}
|
||||
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ context.privateFileSends.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
let initial = try #require(context.privateFileSends.first)
|
||||
coordinator.handleTransferEvent(.completed(
|
||||
id: initial.transferId,
|
||||
totalFragments: 1
|
||||
))
|
||||
coordinator.peerDidReconnect(peerID)
|
||||
let retryID = try #require(
|
||||
context.privateFileReceiptRetryTransferIDs.first
|
||||
)
|
||||
coordinator.handleTransferEvent(.cancelled(
|
||||
id: retryID,
|
||||
sentFragments: 0,
|
||||
totalFragments: 1
|
||||
))
|
||||
|
||||
#expect(coordinator.retainedReconnectRetryCount == 0)
|
||||
#expect(context.removedMessages.isEmpty)
|
||||
#expect(context.deliveryStatusUpdates.contains {
|
||||
$0.messageID.hasPrefix("media-")
|
||||
&& $0.status == .failed(reason: String(
|
||||
localized: "content.delivery.reason.not_delivered",
|
||||
defaultValue: "Not delivered",
|
||||
comment: "Failure reason shown when a private media transfer could not finish"
|
||||
))
|
||||
})
|
||||
|
||||
// A separate retained row that locally completed but never received a
|
||||
// remote receipt expires to a distinct visible failure.
|
||||
let ttlFileName = "voice_5555666677778888.m4a"
|
||||
let ttlPreparer = StaticVoiceNotePreparer(
|
||||
fileName: ttlFileName
|
||||
)
|
||||
let ttlCoordinator = ChatMediaTransferCoordinator(
|
||||
context: context,
|
||||
prepareVoiceNotePacket: { url in
|
||||
try ttlPreparer.prepare(url)
|
||||
},
|
||||
reconnectRetryLimits: limits,
|
||||
now: clock.now,
|
||||
transferIDFactory: transferIDs.make
|
||||
)
|
||||
let ttlURL = try makeCoordinatorVoiceURL(
|
||||
fileName: ttlFileName
|
||||
)
|
||||
defer {
|
||||
try? FileManager.default.removeItem(
|
||||
at: ttlURL.deletingLastPathComponent()
|
||||
)
|
||||
}
|
||||
let sendsBeforeTTL = context.privateFileSends.count
|
||||
ttlCoordinator.sendVoiceNote(at: ttlURL)
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{
|
||||
context.privateFileSends.count
|
||||
== sendsBeforeTTL + 1
|
||||
},
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
let ttlInitial = try #require(context.privateFileSends.last)
|
||||
ttlCoordinator.handleTransferEvent(.completed(
|
||||
id: ttlInitial.transferId,
|
||||
totalFragments: 1
|
||||
))
|
||||
clock.advance(by: 10)
|
||||
ttlCoordinator._test_expireReconnectRetries()
|
||||
|
||||
#expect(ttlCoordinator.retainedReconnectRetryCount == 0)
|
||||
#expect(context.deliveryStatusUpdates.contains {
|
||||
$0.status == .failed(
|
||||
reason: String(
|
||||
localized:
|
||||
"content.delivery.reason.private_media_delivery_unconfirmed",
|
||||
defaultValue: "Delivery could not be confirmed"
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func retentionAndPerReconnectWorkAreBounded() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
context.supportsAuthenticatedPrivateMediaReceipts = true
|
||||
let limits = PrivateMediaReconnectRetryLimits(
|
||||
maxRetainedPackets: 2,
|
||||
maxRetainedBytes: 10,
|
||||
maxRetriesPerMessage: 2,
|
||||
retentionSeconds: 120,
|
||||
maxRetriesPerReconnect: 1
|
||||
)
|
||||
let transferIDs = DeterministicMediaTransferIDFactory()
|
||||
let coordinator = ChatMediaTransferCoordinator(
|
||||
context: context,
|
||||
prepareVoiceNotePacket: { url in
|
||||
let content = Data("voice".utf8)
|
||||
return BitchatFilePacket(
|
||||
fileName: url.lastPathComponent,
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "audio/mp4",
|
||||
content: content
|
||||
)
|
||||
},
|
||||
reconnectRetryLimits: limits,
|
||||
transferIDFactory: transferIDs.make
|
||||
)
|
||||
let fileNames = [
|
||||
"voice_6666777788889999.m4a",
|
||||
"voice_777788889999aaaa.m4a",
|
||||
"voice_88889999aaaabbbb.m4a"
|
||||
]
|
||||
var roots: [URL] = []
|
||||
defer {
|
||||
for root in roots {
|
||||
try? FileManager.default.removeItem(at: root)
|
||||
}
|
||||
}
|
||||
|
||||
for fileName in fileNames {
|
||||
let url = try makeCoordinatorVoiceURL(
|
||||
fileName: fileName,
|
||||
bytes: Data("voice".utf8)
|
||||
)
|
||||
roots.append(url.deletingLastPathComponent())
|
||||
// The production preparer preserves this stable filename.
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
}
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ context.privateFileSends.count == 3 },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
#expect(coordinator.retainedReconnectRetryCount == 2)
|
||||
#expect(coordinator.retainedReconnectRetryBytes <= 10)
|
||||
|
||||
for send in context.privateFileSends {
|
||||
coordinator.handleTransferEvent(.completed(
|
||||
id: send.transferId,
|
||||
totalFragments: 1
|
||||
))
|
||||
}
|
||||
coordinator.peerDidReconnect(peerID)
|
||||
#expect(context.privateFileReceiptRetryTransferIDs.count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func userCancellationReleasesRetainedBytesAndIgnoresLateEvent() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
context.supportsAuthenticatedPrivateMediaReceipts = true
|
||||
let fileName = "voice_9999aaaabbbbcccc.m4a"
|
||||
let preparer = StaticVoiceNotePreparer(fileName: fileName)
|
||||
let coordinator = ChatMediaTransferCoordinator(
|
||||
context: context,
|
||||
prepareVoiceNotePacket: { url in
|
||||
try preparer.prepare(url)
|
||||
}
|
||||
)
|
||||
let url = try makeCoordinatorVoiceURL(fileName: fileName)
|
||||
defer {
|
||||
try? FileManager.default.removeItem(
|
||||
at: url.deletingLastPathComponent()
|
||||
)
|
||||
}
|
||||
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ context.privateFileSends.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
let messageID = try #require(
|
||||
context.privateChats[peerID]?.first?.id
|
||||
)
|
||||
let transferID = try #require(
|
||||
context.privateFileSends.first?.transferId
|
||||
)
|
||||
|
||||
coordinator.cancelMediaSend(messageID: messageID)
|
||||
coordinator.handleTransferEvent(.cancelled(
|
||||
id: transferID,
|
||||
sentFragments: 0,
|
||||
totalFragments: 1
|
||||
))
|
||||
|
||||
#expect(coordinator.retainedReconnectRetryCount == 0)
|
||||
#expect(coordinator.retainedReconnectRetryBytes == 0)
|
||||
#expect(context.cancelledTransfers == [transferID])
|
||||
#expect(context.removedMessages.map(\.messageID) == [
|
||||
messageID
|
||||
])
|
||||
#expect(!context.deliveryStatusUpdates.contains {
|
||||
if case .failed = $0.status { return true }
|
||||
return false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private func makeCoordinatorVoiceURL(
|
||||
fileName: String,
|
||||
bytes: Data = Data("voice".utf8)
|
||||
) throws -> URL {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(
|
||||
"media-retry-\(UUID().uuidString)",
|
||||
isDirectory: true
|
||||
)
|
||||
try FileManager.default.createDirectory(
|
||||
at: directory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let url = directory.appendingPathComponent(fileName)
|
||||
try bytes.write(to: url)
|
||||
return url
|
||||
}
|
||||
|
||||
private final class PausedImagePreparer: @unchecked Sendable {
|
||||
|
||||
@@ -104,6 +104,20 @@ private func makeMessage(id: String, senderPeerID: PeerID? = nil) -> BitchatMess
|
||||
/// no `ChatViewModel`.
|
||||
struct ChatPeerListCoordinatorContextTests {
|
||||
|
||||
@Test @MainActor
|
||||
func synchronousPeerListUpdate_appliesBeforeReturning() {
|
||||
let context = MockChatPeerListContext()
|
||||
let coordinator = ChatPeerListCoordinator(context: context)
|
||||
let peerID = PeerID(str: "0011223344556677")
|
||||
|
||||
coordinator.didUpdatePeerListSynchronously([peerID])
|
||||
|
||||
#expect(context.isConnected)
|
||||
#expect(context.registeredEphemeralSessions == [peerID])
|
||||
#expect(context.updateEncryptionStatusForPeersCount == 1)
|
||||
#expect(context.cleanupOldReadReceiptsCount == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func didUpdatePeerList_updatesConnectionSessionsAndEncryptionStatus() async {
|
||||
let context = MockChatPeerListContext()
|
||||
|
||||
@@ -220,26 +220,85 @@ struct ChatTransportEventCoordinatorContextTests {
|
||||
func didReceiveMessage_routesPrivateAndPublic_skipsBlockedAndEmpty() async {
|
||||
let context = MockChatTransportEventContext()
|
||||
let coordinator = ChatTransportEventCoordinator(context: context)
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
|
||||
// Blocked messages are dropped before any handling.
|
||||
context.blockedMessageIDs = ["blocked"]
|
||||
context.blockedMessageIDs = ["blocked", "blocked-private"]
|
||||
coordinator.didReceiveMessage(makeMessage(id: "blocked"))
|
||||
coordinator.didReceiveMessage(makeMessage(
|
||||
id: "blocked-private",
|
||||
isPrivate: true,
|
||||
senderPeerID: peerID
|
||||
))
|
||||
// Empty public content is dropped too.
|
||||
coordinator.didReceiveMessage(makeMessage(id: "empty", content: " "))
|
||||
await drainMainActorTasks()
|
||||
#expect(context.handledPublicMessages.isEmpty)
|
||||
#expect(context.handledPrivateMessages.isEmpty)
|
||||
#expect(context.mentionCheckedMessageIDs.isEmpty)
|
||||
#expect(context.meshDeliveryAcks.isEmpty)
|
||||
|
||||
// Private goes to the private handler, public to the public handler;
|
||||
// both get mention checks and haptics.
|
||||
coordinator.didReceiveMessage(makeMessage(id: "pm", isPrivate: true))
|
||||
// both get mention checks and haptics. Stable-media ACK authorization
|
||||
// belongs to BLEFileTransferHandler after its durable commit and this
|
||||
// synchronous acceptance result, not to the generic UI coordinator.
|
||||
let stableMediaID = "media-\(String(repeating: "a", count: 32))"
|
||||
coordinator.didReceiveMessage(makeMessage(
|
||||
id: stableMediaID,
|
||||
isPrivate: true,
|
||||
senderPeerID: peerID
|
||||
))
|
||||
coordinator.didReceiveMessage(makeMessage(
|
||||
id: "legacy-media",
|
||||
isPrivate: true,
|
||||
senderPeerID: peerID
|
||||
))
|
||||
coordinator.didReceiveMessage(makeMessage(id: "pm-missing-sender", isPrivate: true))
|
||||
coordinator.didReceiveMessage(makeMessage(id: "pub"))
|
||||
await drainMainActorTasks()
|
||||
#expect(context.handledPrivateMessages.map(\.id) == ["pm"])
|
||||
#expect(context.handledPrivateMessages.map(\.id) == [
|
||||
stableMediaID,
|
||||
"legacy-media",
|
||||
"pm-missing-sender"
|
||||
])
|
||||
#expect(context.handledPublicMessages.map(\.id) == ["pub"])
|
||||
#expect(context.mentionCheckedMessageIDs == ["pm", "pub"])
|
||||
#expect(context.hapticMessageIDs == ["pm", "pub"])
|
||||
#expect(context.mentionCheckedMessageIDs == [
|
||||
stableMediaID,
|
||||
"legacy-media",
|
||||
"pm-missing-sender",
|
||||
"pub"
|
||||
])
|
||||
#expect(context.hapticMessageIDs == [
|
||||
stableMediaID,
|
||||
"legacy-media",
|
||||
"pm-missing-sender",
|
||||
"pub"
|
||||
])
|
||||
#expect(context.meshDeliveryAcks.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func synchronousMessageDeliveryReportsAcceptanceForAckGating() {
|
||||
let context = MockChatTransportEventContext()
|
||||
let coordinator = ChatTransportEventCoordinator(context: context)
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
let blocked = makeMessage(
|
||||
id: "blocked-private-media",
|
||||
isPrivate: true,
|
||||
senderPeerID: peerID
|
||||
)
|
||||
context.blockedMessageIDs = [blocked.id]
|
||||
|
||||
#expect(coordinator.didReceiveMessageSynchronously(blocked) == false)
|
||||
#expect(context.handledPrivateMessages.isEmpty)
|
||||
|
||||
let accepted = makeMessage(
|
||||
id: "accepted-private-media",
|
||||
isPrivate: true,
|
||||
senderPeerID: peerID
|
||||
)
|
||||
#expect(coordinator.didReceiveMessageSynchronously(accepted) == true)
|
||||
#expect(context.handledPrivateMessages.map(\.id) == [accepted.id])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
@@ -295,6 +354,32 @@ struct ChatTransportEventCoordinatorContextTests {
|
||||
#expect(context.notifyUIChangedCount == 2)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func synchronousConnectAndDisconnect_applyBeforeReturning() {
|
||||
let context = MockChatTransportEventContext()
|
||||
let coordinator = ChatTransportEventCoordinator(context: context)
|
||||
let peerID = PeerID(str: "2233445566778899")
|
||||
let incoming = makeMessage(
|
||||
id: "incoming-receipt",
|
||||
isPrivate: true,
|
||||
senderPeerID: peerID
|
||||
)
|
||||
context.privateChats[peerID] = [incoming]
|
||||
|
||||
coordinator.didConnectToPeerSynchronously(peerID)
|
||||
|
||||
#expect(context.isConnected)
|
||||
#expect(context.registeredEphemeralSessions == [peerID])
|
||||
#expect(context.flushedOutboxPeerIDs == [peerID])
|
||||
#expect(context.courierRetryPeerIDs == [peerID])
|
||||
|
||||
coordinator.didDisconnectFromPeerSynchronously(peerID)
|
||||
|
||||
#expect(context.removedEphemeralSessions == [peerID])
|
||||
#expect(context.unmarkedReadReceiptBatches == [[incoming.id]])
|
||||
#expect(context.notifyUIChangedCount == 2)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func didDisconnect_whileViewingChat_migratesConversationToStablePeerID() async {
|
||||
let context = MockChatTransportEventContext()
|
||||
|
||||
@@ -97,6 +97,7 @@ private final class MockChatVerificationContext: ChatVerificationContext {
|
||||
var noiseSessionKeysByPeerID: [PeerID: Data] = [:]
|
||||
private(set) var installedCallbacks: (onPeerAuthenticated: (PeerID, String) -> Void, onHandshakeRequired: (PeerID) -> Void)?
|
||||
private(set) var triggeredHandshakes: [PeerID] = []
|
||||
private(set) var privateMediaAuthenticatedPeers: [PeerID] = []
|
||||
private(set) var sentChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
||||
private(set) var sentResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
||||
|
||||
@@ -113,6 +114,9 @@ private final class MockChatVerificationContext: ChatVerificationContext {
|
||||
establishedNoiseSessions.contains(peerID)
|
||||
}
|
||||
func triggerHandshake(with peerID: PeerID) { triggeredHandshakes.append(peerID) }
|
||||
func privateMediaPeerDidAuthenticate(_ peerID: PeerID) {
|
||||
privateMediaAuthenticatedPeers.append(peerID)
|
||||
}
|
||||
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
|
||||
sentChallenges.append((peerID, noiseKeyHex, nonceA))
|
||||
@@ -277,6 +281,7 @@ struct ChatVerificationCoordinatorContextTests {
|
||||
#expect(context.encryptionStatuses[peerID] == .noiseVerified)
|
||||
#expect(context.stablePeerIDCache[peerID] == PeerID(hexData: noiseKey))
|
||||
#expect(context.invalidatedEncryptionCachePeers.contains(peerID))
|
||||
#expect(context.privateMediaAuthenticatedPeers == [peerID])
|
||||
|
||||
// Handshake required -> handshaking status.
|
||||
callbacks?.onHandshakeRequired(peerID)
|
||||
|
||||
@@ -864,6 +864,80 @@ struct ChatViewModelPublicConversationTests {
|
||||
|
||||
struct ChatViewModelPeerTests {
|
||||
|
||||
@Test @MainActor
|
||||
func typedPeerLifecycleEvents_applyBeforeReturning() {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
let incoming = BitchatMessage(
|
||||
id: "typed-peer-incoming",
|
||||
sender: "Alice",
|
||||
content: "Hello",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: viewModel.nickname,
|
||||
senderPeerID: peerID
|
||||
)
|
||||
viewModel.seedPrivateChat([incoming], for: peerID)
|
||||
viewModel.sentReadReceipts.insert(incoming.id)
|
||||
|
||||
viewModel.didReceiveTransportEvent(.peerConnected(peerID))
|
||||
|
||||
#expect(viewModel.isConnected)
|
||||
|
||||
viewModel.didReceiveTransportEvent(.peerDisconnected(peerID))
|
||||
|
||||
#expect(!viewModel.sentReadReceipts.contains(incoming.id))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func typedPeerListDeliveryAndBluetoothEvents_applyBeforeReturning() {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let stalePeer = PeerID(str: "00000000000000a2")
|
||||
let deliveryPeer = PeerID(str: "0102030405060708")
|
||||
let messageID = "typed-delivery-status"
|
||||
let delivered = DeliveryStatus.delivered(
|
||||
to: "Alice",
|
||||
at: Date(timeIntervalSince1970: 1_234)
|
||||
)
|
||||
let outgoing = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
content: "On the way",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Alice",
|
||||
senderPeerID: transport.myPeerID,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
viewModel.markPrivateChatUnread(stalePeer)
|
||||
viewModel.seedPrivateChat([outgoing], for: deliveryPeer)
|
||||
|
||||
viewModel.didReceiveTransportEvent(.peerListUpdated([]))
|
||||
#expect(!viewModel.unreadPrivateMessages.contains(stalePeer))
|
||||
|
||||
viewModel.didReceiveTransportEvent(
|
||||
.messageDeliveryStatusUpdated(
|
||||
messageID: messageID,
|
||||
status: delivered
|
||||
)
|
||||
)
|
||||
#expect(
|
||||
viewModel.privateMessages(for: deliveryPeer).first?.deliveryStatus
|
||||
== delivered
|
||||
)
|
||||
|
||||
viewModel.didReceiveTransportEvent(.bluetoothStateUpdated(.poweredOff))
|
||||
#expect(viewModel.bluetoothState == .poweredOff)
|
||||
#expect(viewModel.showBluetoothAlert)
|
||||
|
||||
// Snapshot events belong to TransportPeerEventsDelegate and are
|
||||
// intentionally ignored at this typed sink.
|
||||
viewModel.didReceiveTransportEvent(.peerSnapshotsUpdated([]))
|
||||
#expect(viewModel.bluetoothState == .poweredOff)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func didConnectToPeer_notifiesDelegate() async {
|
||||
let (_, transport) = makeTestableViewModel()
|
||||
|
||||
@@ -364,6 +364,217 @@ struct PrivateMediaEndToEndTests {
|
||||
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .legacyRequiresConsent)
|
||||
}
|
||||
|
||||
@Test
|
||||
func privateMediaRetryRequiresExactAuthenticatedBit9Proof() async throws {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(
|
||||
"private-media-receipt-proof-\(UUID().uuidString)",
|
||||
isDirectory: true
|
||||
)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let alice = makeService(
|
||||
baseDirectory: root.appendingPathComponent(
|
||||
"alice",
|
||||
isDirectory: true
|
||||
)
|
||||
)
|
||||
let bob = makeService(
|
||||
baseDirectory: root.appendingPathComponent(
|
||||
"bob",
|
||||
isDirectory: true
|
||||
)
|
||||
)
|
||||
let bothCapabilities: PeerCapabilities = [
|
||||
.privateMedia,
|
||||
.privateMediaReceipts
|
||||
]
|
||||
|
||||
// A public bit-9 announce is discovery only.
|
||||
alice._test_seedConnectedPeer(
|
||||
bob.myPeerID,
|
||||
nickname: "Bob",
|
||||
capabilities: bothCapabilities,
|
||||
noisePublicKey: bob.noiseStaticPublicKeyData()
|
||||
)
|
||||
#expect(
|
||||
alice.authenticatedPrivateMediaReceiptSessionGeneration(
|
||||
to: bob.myPeerID
|
||||
) == nil
|
||||
)
|
||||
|
||||
let proofs = try await establishSessionCapturingPeerState(
|
||||
alice: alice,
|
||||
bob: bob
|
||||
)
|
||||
#expect(
|
||||
alice.authenticatedPrivateMediaReceiptSessionGeneration(
|
||||
to: bob.myPeerID
|
||||
) == nil
|
||||
)
|
||||
|
||||
// Bit 8 alone preserves encrypted transfer compatibility but cannot
|
||||
// authorize automatic resend.
|
||||
let privateMediaOnly = try authenticatedPeerStatePacket(
|
||||
from: bob,
|
||||
to: alice,
|
||||
capabilities: .privateMedia
|
||||
)
|
||||
alice._test_handlePacket(
|
||||
privateMediaOnly,
|
||||
fromPeerID: bob.myPeerID
|
||||
)
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{
|
||||
alice.privateMediaSendPolicy(to: bob.myPeerID)
|
||||
== .encrypted
|
||||
},
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
#expect(
|
||||
alice.authenticatedPrivateMediaReceiptSessionGeneration(
|
||||
to: bob.myPeerID
|
||||
) == nil
|
||||
)
|
||||
|
||||
let receiptCapable = try authenticatedPeerStatePacket(
|
||||
from: bob,
|
||||
to: alice,
|
||||
capabilities: bothCapabilities
|
||||
)
|
||||
alice._test_handlePacket(
|
||||
receiptCapable,
|
||||
fromPeerID: bob.myPeerID
|
||||
)
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{
|
||||
alice.authenticatedPrivateMediaReceiptSessionGeneration(
|
||||
to: bob.myPeerID
|
||||
) != nil
|
||||
},
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
|
||||
bob._test_handlePacket(
|
||||
proofs.alice,
|
||||
fromPeerID: alice.myPeerID
|
||||
)
|
||||
alice._test_onOutboundPacket = nil
|
||||
bob._test_onOutboundPacket = nil
|
||||
}
|
||||
|
||||
@Test
|
||||
func receiptRetryRechecksBit9AtDeferredTransportBoundary() async throws {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(
|
||||
"private-media-retry-proof-race-\(UUID().uuidString)",
|
||||
isDirectory: true
|
||||
)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let alice = makeService(
|
||||
baseDirectory: root.appendingPathComponent(
|
||||
"alice",
|
||||
isDirectory: true
|
||||
)
|
||||
)
|
||||
let bob = makeService(
|
||||
baseDirectory: root.appendingPathComponent(
|
||||
"bob",
|
||||
isDirectory: true
|
||||
)
|
||||
)
|
||||
let receiptCapabilities: PeerCapabilities = [
|
||||
.privateMedia,
|
||||
.privateMediaReceipts
|
||||
]
|
||||
alice._test_seedConnectedPeer(
|
||||
bob.myPeerID,
|
||||
nickname: "Bob",
|
||||
capabilities: receiptCapabilities,
|
||||
noisePublicKey: bob.noiseStaticPublicKeyData()
|
||||
)
|
||||
bob._test_seedConnectedPeer(
|
||||
alice.myPeerID,
|
||||
nickname: "Alice",
|
||||
capabilities: receiptCapabilities,
|
||||
noisePublicKey: alice.noiseStaticPublicKeyData()
|
||||
)
|
||||
try await establishSession(alice: alice, bob: bob)
|
||||
#expect(
|
||||
alice.authenticatedPrivateMediaReceiptSessionGeneration(
|
||||
to: bob.myPeerID
|
||||
) != nil
|
||||
)
|
||||
|
||||
let privateMediaOnly = try authenticatedPeerStatePacket(
|
||||
from: bob,
|
||||
to: alice,
|
||||
capabilities: .privateMedia
|
||||
)
|
||||
let transferID =
|
||||
"receipt-proof-race-\(UUID().uuidString)"
|
||||
let tap = PacketTap()
|
||||
let boundaryProofs = ReceiptCapabilityRecorder()
|
||||
let rejections = TransferCancellationRecorder()
|
||||
let cancellable = TransferProgressManager.shared.publisher.sink {
|
||||
rejections.record($0)
|
||||
}
|
||||
alice._test_onOutboundPacket = tap.record
|
||||
alice._test_beforePrivateMediaDeferredSend = { id in
|
||||
guard id == transferID else { return }
|
||||
boundaryProofs.record(
|
||||
alice
|
||||
.authenticatedPrivateMediaReceiptSessionGeneration(
|
||||
to: bob.myPeerID
|
||||
) != nil
|
||||
)
|
||||
}
|
||||
defer {
|
||||
alice._test_beforePrivateMediaDeferredSend = nil
|
||||
alice._test_onOutboundPacket = nil
|
||||
}
|
||||
|
||||
// Rotate authenticated state before the deferred retry reaches its
|
||||
// admission boundary.
|
||||
alice._test_handlePacket(
|
||||
privateMediaOnly,
|
||||
fromPeerID: bob.myPeerID
|
||||
)
|
||||
let content = Data("%PDF-1.7\nreceipt-proof-race".utf8)
|
||||
alice.sendFilePrivateReceiptRetry(
|
||||
BitchatFilePacket(
|
||||
fileName: "receipt-proof-race.pdf",
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "application/pdf",
|
||||
content: content
|
||||
),
|
||||
to: bob.myPeerID,
|
||||
transferId: transferID
|
||||
)
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ boundaryProofs.snapshot() == [false] },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
await alice._test_drainPrivateMediaSendPipeline()
|
||||
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ rejections.contains(transferID) },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
#expect(tap.snapshot().allSatisfy {
|
||||
$0.type != MessageType.fileTransfer.rawValue
|
||||
&& !(
|
||||
$0.type == MessageType.noiseEncrypted.rawValue
|
||||
&& $0.version == 2
|
||||
)
|
||||
})
|
||||
let state = alice._test_privateMediaTransferState(
|
||||
transferId: transferID
|
||||
)
|
||||
#expect(!state.admissionActive)
|
||||
#expect(!state.pendingNoise)
|
||||
_ = cancellable
|
||||
}
|
||||
|
||||
@Test
|
||||
func capabilityAnnounceCannotPoisonPinWithoutMatchingNoiseAuthentication() async throws {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
@@ -682,13 +893,18 @@ struct PrivateMediaEndToEndTests {
|
||||
#expect(!identity.hasObservedPrivateMediaCapability(
|
||||
fingerprint: impostorKey.sha256Fingerprint()
|
||||
))
|
||||
#expect(identity.hasObservedPrivateMediaCapability(
|
||||
fingerprint: bob.noiseStaticPublicKeyData().sha256Fingerprint()
|
||||
))
|
||||
alice._test_seedConnectedPeer(
|
||||
bob.myPeerID,
|
||||
nickname: "Bob",
|
||||
capabilities: [],
|
||||
noisePublicKey: impostorKey
|
||||
)
|
||||
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .legacyRequiresConsent)
|
||||
// The exact live Noise identity remains authoritative over a later
|
||||
// impostor registry rewrite.
|
||||
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -741,7 +957,7 @@ struct PrivateMediaEndToEndTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func consentedLegacySendRejectsAboveAndroidFragmentCapButEncryptedDoesNot() async throws {
|
||||
func encryptedAndConsentedLegacySendsRejectAboveAndroidFragmentCap() async throws {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("private-media-fragment-cap-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
@@ -790,24 +1006,14 @@ struct PrivateMediaEndToEndTests {
|
||||
allowLegacyFallback: true
|
||||
)
|
||||
|
||||
// The directed raw-file migration fallback (Android-style peer without
|
||||
// the .privateMedia capability) still honors the 256-fragment ceiling.
|
||||
let legacyRejected = await TestHelpers.waitUntil(
|
||||
{ rejections.contains(legacyID) },
|
||||
let bothRejected = await TestHelpers.waitUntil(
|
||||
{ rejections.contains(encryptedID) && rejections.contains(legacyID) },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(legacyRejected)
|
||||
#expect(bothRejected)
|
||||
#expect(rejections.reason(for: encryptedID)?.contains("256") == true)
|
||||
#expect(rejections.reason(for: legacyID)?.contains("256") == true)
|
||||
|
||||
// Encrypted private media to a .privateMedia-capable peer is NOT forced
|
||||
// down to Android's 256 cap: it uses the full receiver ceiling and
|
||||
// proceeds to fragment/emit (a 130 KiB file exceeds 256 fragments).
|
||||
let encryptedEmitted = await TestHelpers.waitUntil(
|
||||
{ !tap.snapshot().isEmpty },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(encryptedEmitted, "Encrypted send to a capable peer must not be blocked by the Android cap")
|
||||
#expect(!rejections.contains(encryptedID))
|
||||
#expect(tap.snapshot().isEmpty, "No outer packet or fragment may be exposed before size rejection")
|
||||
_ = cancellable
|
||||
}
|
||||
|
||||
@@ -876,7 +1082,7 @@ struct PrivateMediaEndToEndTests {
|
||||
+ marker
|
||||
+ Data(repeating: 0x4A, count: 6 * 1024)
|
||||
try await assertPrivateMediaRoundTrip(
|
||||
fileName: "private.jpg",
|
||||
fileName: "img_20260725_120000_11111111-1111-1111-1111-111111111111.jpg",
|
||||
mimeType: "image/jpeg",
|
||||
content: content,
|
||||
marker: marker,
|
||||
@@ -1147,6 +1353,17 @@ struct PrivateMediaEndToEndTests {
|
||||
#expect(message.isPrivate)
|
||||
#expect(message.senderPeerID == alice.myPeerID)
|
||||
#expect(message.content.hasPrefix(expectedMessagePrefix))
|
||||
if let stableMessageID = PrivateMediaMessageIdentity.stableID(
|
||||
for: file,
|
||||
senderPeerID: alice.myPeerID,
|
||||
recipientPeerID: bob.myPeerID
|
||||
) {
|
||||
#expect(message.id == stableMessageID)
|
||||
} else {
|
||||
// Generic/legacy filenames retain random per-arrival IDs so two
|
||||
// unrelated "photo.jpg" transfers are never deduplicated.
|
||||
#expect(!message.id.hasPrefix("media-"))
|
||||
}
|
||||
|
||||
let stored = recursivelyStoredFiles(under: bobRoot)
|
||||
#expect(stored.count == 1)
|
||||
@@ -1289,6 +1506,8 @@ struct PrivateMediaEndToEndTests {
|
||||
|
||||
return enumerator.compactMap { item in
|
||||
guard let url = item as? URL,
|
||||
!url.pathComponents.contains(".private-media-receipts"),
|
||||
url.lastPathComponent != ".private-media-receipts.json",
|
||||
(try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true else {
|
||||
return nil
|
||||
}
|
||||
@@ -1389,6 +1608,23 @@ private final class PrivateMediaPolicyRecorder: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
private final class ReceiptCapabilityRecorder: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var values: [Bool] = []
|
||||
|
||||
func record(_ value: Bool) {
|
||||
lock.lock()
|
||||
values.append(value)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func snapshot() -> [Bool] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return values
|
||||
}
|
||||
}
|
||||
|
||||
private final class MessageCaptureDelegate: BitchatDelegate, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var messages: [BitchatMessage] = []
|
||||
|
||||
@@ -12,6 +12,7 @@ import Testing
|
||||
@testable import BitFoundation // to avoid unnecessary public's
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("Integration Tests", .serialized)
|
||||
struct IntegrationTests {
|
||||
|
||||
private var helper = TestNetworkHelper()
|
||||
@@ -272,8 +273,18 @@ struct IntegrationTests {
|
||||
// Re-establish Noise handshake explicitly via managers
|
||||
do {
|
||||
let m1 = try helper.noiseManagers["Bob"]!.initiateHandshake(with: helper.nodes["Alice"]!.peerID)
|
||||
let m2 = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m1)!
|
||||
let m3 = try helper.noiseManagers["Bob"]!.handleIncomingHandshake(from: helper.nodes["Alice"]!.peerID, message: m2)!
|
||||
let m2 = try #require(
|
||||
try helper.noiseManagers["Alice"]!.handleIncomingHandshake(
|
||||
from: helper.nodes["Bob"]!.peerID,
|
||||
message: m1
|
||||
)
|
||||
)
|
||||
let m3 = try #require(
|
||||
try helper.noiseManagers["Bob"]!.handleIncomingHandshake(
|
||||
from: helper.nodes["Alice"]!.peerID,
|
||||
message: m2
|
||||
)
|
||||
)
|
||||
_ = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m3)
|
||||
} catch {
|
||||
Issue.record("Failed to re-establish Noise session after restart: \(error)")
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import Testing
|
||||
@testable import BitFoundation // to avoid unnecessary public's
|
||||
@testable import bitchat
|
||||
|
||||
@@ -27,9 +28,14 @@ final class TestNetworkHelper {
|
||||
node.mockNickname = name
|
||||
nodes[name] = node
|
||||
|
||||
// Create/replace Noise manager for this node
|
||||
// This synchronous helper directly drives all three XX messages and
|
||||
// has no transport callback loop for delayed collision recovery.
|
||||
let key = Curve25519.KeyAgreement.PrivateKey()
|
||||
noiseManagers[name] = NoiseSessionManager(localStaticKey: key, keychain: mockKeychain)
|
||||
noiseManagers[name] = NoiseSessionManager(
|
||||
localStaticKey: key,
|
||||
keychain: mockKeychain,
|
||||
recentInitiatorCompletionGracePeriod: 0
|
||||
)
|
||||
return node
|
||||
}
|
||||
|
||||
@@ -108,8 +114,18 @@ final class TestNetworkHelper {
|
||||
let peer2ID = nodes[node2]?.peerID else { return }
|
||||
|
||||
let msg1 = try manager1.initiateHandshake(with: peer2ID)
|
||||
let msg2 = try manager2.handleIncomingHandshake(from: peer1ID, message: msg1)!
|
||||
let msg3 = try manager1.handleIncomingHandshake(from: peer2ID, message: msg2)!
|
||||
let msg2 = try #require(
|
||||
try manager2.handleIncomingHandshake(
|
||||
from: peer1ID,
|
||||
message: msg1
|
||||
)
|
||||
)
|
||||
let msg3 = try #require(
|
||||
try manager1.handleIncomingHandshake(
|
||||
from: peer2ID,
|
||||
message: msg2
|
||||
)
|
||||
)
|
||||
_ = try manager2.handleIncomingHandshake(from: peer1ID, message: msg3)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import BitFoundation
|
||||
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("Noise Coverage Tests")
|
||||
@Suite("Noise Coverage Tests", .serialized)
|
||||
struct NoiseCoverageTests {
|
||||
private let keychain = MockKeychain()
|
||||
private let aliceStaticKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
@@ -633,8 +633,16 @@ struct NoiseCoverageTests {
|
||||
)
|
||||
let replacementSession = try #require(manager.getSession(for: alicePeerID))
|
||||
|
||||
#expect(replacementResponse != nil)
|
||||
#expect(replacementSession !== restartedSession)
|
||||
let localPeerID = PeerID(
|
||||
publicKey: aliceStaticKey.publicKey.rawRepresentation
|
||||
)
|
||||
if localPeerID < alicePeerID {
|
||||
#expect(replacementResponse == nil)
|
||||
#expect(replacementSession === restartedSession)
|
||||
} else {
|
||||
#expect(replacementResponse != nil)
|
||||
#expect(replacementSession !== restartedSession)
|
||||
}
|
||||
|
||||
let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain)
|
||||
let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain)
|
||||
@@ -654,7 +662,13 @@ struct NoiseCoverageTests {
|
||||
try aliceManager.initiateHandshake(with: alicePeerID)
|
||||
}
|
||||
|
||||
let rekeyHandshake = try aliceManager.initiateRekey(for: alicePeerID)
|
||||
let rekeyInitiation = try aliceManager.initiateRekey(for: alicePeerID)
|
||||
let rekeyHandshake = try #require(
|
||||
aliceManager.claimHandshakeInitiation(
|
||||
rekeyInitiation,
|
||||
for: alicePeerID
|
||||
)
|
||||
)
|
||||
#expect(!rekeyHandshake.isEmpty)
|
||||
let rekeyedSession = try #require(aliceManager.getSession(for: alicePeerID))
|
||||
|
||||
@@ -667,6 +681,7 @@ struct NoiseCoverageTests {
|
||||
let aliceManager = NoiseSessionManager(
|
||||
localStaticKey: aliceStaticKey,
|
||||
keychain: keychain,
|
||||
recentInitiatorCompletionGracePeriod: 0,
|
||||
sessionFactory: { peerID, role in
|
||||
BlockingDecryptNoiseSession(
|
||||
peerID: peerID,
|
||||
|
||||
@@ -357,8 +357,18 @@ struct NoiseProtocolTests {
|
||||
|
||||
@Test func peerRestartDetection() throws {
|
||||
// Establish initial sessions
|
||||
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
|
||||
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
||||
// This test explicitly drives the three synchronous XX messages and
|
||||
// does not exercise the transport's delayed collision recovery.
|
||||
let aliceManager = NoiseSessionManager(
|
||||
localStaticKey: aliceKey,
|
||||
keychain: mockKeychain,
|
||||
recentInitiatorCompletionGracePeriod: 0
|
||||
)
|
||||
let bobManager = NoiseSessionManager(
|
||||
localStaticKey: bobKey,
|
||||
keychain: mockKeychain,
|
||||
recentInitiatorCompletionGracePeriod: 0
|
||||
)
|
||||
|
||||
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
||||
|
||||
@@ -377,15 +387,24 @@ struct NoiseProtocolTests {
|
||||
let newHandshake1 = try bobManagerRestarted.initiateHandshake(with: bobPeerID)
|
||||
|
||||
// Alice should accept the new handshake (clearing old session)
|
||||
let newHandshake2 = try aliceManager.handleIncomingHandshake(
|
||||
from: alicePeerID, message: newHandshake1)
|
||||
#expect(newHandshake2 != nil)
|
||||
let newHandshake2 = try #require(
|
||||
try aliceManager.handleIncomingHandshake(
|
||||
from: alicePeerID,
|
||||
message: newHandshake1
|
||||
)
|
||||
)
|
||||
|
||||
// Complete the new handshake
|
||||
let newHandshake3 = try bobManagerRestarted.handleIncomingHandshake(
|
||||
from: bobPeerID, message: newHandshake2!)
|
||||
#expect(newHandshake3 != nil)
|
||||
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake3!)
|
||||
let newHandshake3 = try #require(
|
||||
try bobManagerRestarted.handleIncomingHandshake(
|
||||
from: bobPeerID,
|
||||
message: newHandshake2
|
||||
)
|
||||
)
|
||||
_ = try aliceManager.handleIncomingHandshake(
|
||||
from: alicePeerID,
|
||||
message: newHandshake3
|
||||
)
|
||||
|
||||
// Should be able to exchange messages with new sessions
|
||||
let testMessage = Data("After restart".utf8)
|
||||
@@ -543,8 +562,18 @@ struct NoiseProtocolTests {
|
||||
|
||||
@Test func nonceDesynchronizationCausesRehandshake() throws {
|
||||
// Test that nonce desynchronization leads to proper re-handshake
|
||||
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
|
||||
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
||||
// This test explicitly drives the three synchronous XX messages and
|
||||
// does not exercise the transport's delayed collision recovery.
|
||||
let aliceManager = NoiseSessionManager(
|
||||
localStaticKey: aliceKey,
|
||||
keychain: mockKeychain,
|
||||
recentInitiatorCompletionGracePeriod: 0
|
||||
)
|
||||
let bobManager = NoiseSessionManager(
|
||||
localStaticKey: bobKey,
|
||||
keychain: mockKeychain,
|
||||
recentInitiatorCompletionGracePeriod: 0
|
||||
)
|
||||
|
||||
// Establish sessions
|
||||
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
||||
@@ -572,15 +601,25 @@ struct NoiseProtocolTests {
|
||||
let rehandshake1 = try bobManager.initiateHandshake(with: bobPeerID)
|
||||
|
||||
// Alice should accept despite having a "valid" (but desynced) session
|
||||
let rehandshake2 = try aliceManager.handleIncomingHandshake(
|
||||
from: alicePeerID, message: rehandshake1)
|
||||
#expect(rehandshake2 != nil, "Alice should accept handshake to fix desync")
|
||||
let rehandshake2 = try #require(
|
||||
try aliceManager.handleIncomingHandshake(
|
||||
from: alicePeerID,
|
||||
message: rehandshake1
|
||||
),
|
||||
"Alice should accept handshake to fix desync"
|
||||
)
|
||||
|
||||
// Complete handshake
|
||||
let rehandshake3 = try bobManager.handleIncomingHandshake(
|
||||
from: bobPeerID, message: rehandshake2!)
|
||||
#expect(rehandshake3 != nil)
|
||||
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: rehandshake3!)
|
||||
let rehandshake3 = try #require(
|
||||
try bobManager.handleIncomingHandshake(
|
||||
from: bobPeerID,
|
||||
message: rehandshake2
|
||||
)
|
||||
)
|
||||
_ = try aliceManager.handleIncomingHandshake(
|
||||
from: alicePeerID,
|
||||
message: rehandshake3
|
||||
)
|
||||
|
||||
// Verify communication works again
|
||||
let testResynced = Data("Resynced".utf8)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import BitFoundation
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
@@ -73,4 +74,85 @@ final class BitchatFilePacketTests: XCTestCase {
|
||||
XCTAssertEqual(decoded.fileSize, UInt64(content.count))
|
||||
XCTAssertEqual(decoded.content, content)
|
||||
}
|
||||
|
||||
func testPrivateMediaMessageIdentityConvergesAcrossPeerIDAliases() throws {
|
||||
let senderKey = Data(repeating: 0x11, count: 32)
|
||||
let recipientKey = Data(repeating: 0x22, count: 32)
|
||||
let senderStable = PeerID(hexData: senderKey)
|
||||
let recipientStable = PeerID(hexData: recipientKey)
|
||||
let fileName = "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
|
||||
|
||||
let senderID = try XCTUnwrap(PrivateMediaMessageIdentity.stableID(
|
||||
senderPeerID: senderStable.toShort(),
|
||||
recipientPeerID: PeerID(str: "mesh:\(recipientStable.toShort().bare)"),
|
||||
fileName: fileName
|
||||
))
|
||||
let receiverID = try XCTUnwrap(PrivateMediaMessageIdentity.stableID(
|
||||
senderPeerID: senderStable,
|
||||
recipientPeerID: recipientStable.toShort(),
|
||||
fileName: fileName
|
||||
))
|
||||
|
||||
XCTAssertEqual(senderID, receiverID)
|
||||
XCTAssertTrue(senderID.hasPrefix("media-"))
|
||||
XCTAssertEqual(senderID.count, 38)
|
||||
XCTAssertTrue(PrivateMediaMessageIdentity.isStableID(senderID))
|
||||
XCTAssertFalse(PrivateMediaMessageIdentity.isStableID("media-\(String(repeating: "A", count: 32))"))
|
||||
XCTAssertFalse(PrivateMediaMessageIdentity.isStableID("media-\(String(repeating: "a", count: 31))"))
|
||||
XCTAssertFalse(PrivateMediaMessageIdentity.isStableID(UUID().uuidString))
|
||||
}
|
||||
|
||||
func testPrivateMediaMessageIdentitySeparatesDirectionAndFilename() throws {
|
||||
let alice = PeerID(str: "0011223344556677")
|
||||
let bob = PeerID(str: "8899aabbccddeeff")
|
||||
let firstName = "voice_20260725_105708_11111111-1111-1111-1111-111111111111.m4a"
|
||||
let secondName = "voice_20260725_105709_22222222-2222-2222-2222-222222222222.m4a"
|
||||
let first = try XCTUnwrap(PrivateMediaMessageIdentity.stableID(
|
||||
senderPeerID: alice,
|
||||
recipientPeerID: bob,
|
||||
fileName: firstName
|
||||
))
|
||||
|
||||
XCTAssertNotEqual(first, PrivateMediaMessageIdentity.stableID(
|
||||
senderPeerID: bob,
|
||||
recipientPeerID: alice,
|
||||
fileName: firstName
|
||||
))
|
||||
XCTAssertNotEqual(first, PrivateMediaMessageIdentity.stableID(
|
||||
senderPeerID: alice,
|
||||
recipientPeerID: bob,
|
||||
fileName: secondName
|
||||
))
|
||||
XCTAssertNil(PrivateMediaMessageIdentity.stableID(
|
||||
senderPeerID: alice,
|
||||
recipientPeerID: bob,
|
||||
fileName: nil
|
||||
))
|
||||
XCTAssertNil(PrivateMediaMessageIdentity.stableID(
|
||||
senderPeerID: alice,
|
||||
recipientPeerID: bob,
|
||||
fileName: "photo.jpg"
|
||||
))
|
||||
XCTAssertNil(PrivateMediaMessageIdentity.stableID(
|
||||
senderPeerID: alice,
|
||||
recipientPeerID: bob,
|
||||
fileName: "img_11111111-1111-1111-1111-111111111111.pdf"
|
||||
))
|
||||
XCTAssertNotNil(PrivateMediaMessageIdentity.stableID(
|
||||
senderPeerID: alice,
|
||||
recipientPeerID: bob,
|
||||
fileName: "voice_0011223344556677.m4a"
|
||||
))
|
||||
}
|
||||
|
||||
func testPrivateMediaMessageIdentityMatchesVersionOneGoldenVector() {
|
||||
XCTAssertEqual(
|
||||
PrivateMediaMessageIdentity.stableID(
|
||||
senderPeerID: PeerID(str: "0011223344556677"),
|
||||
recipientPeerID: PeerID(str: "8899aabbccddeeff"),
|
||||
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
|
||||
),
|
||||
"media-910bd42c65060ab76bb6406f220c4516"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,27 @@ struct BLEFileTransferHandlerTests {
|
||||
|
||||
var signatureVerifyCount = 0
|
||||
var signedNameQueries: [PeerID] = []
|
||||
var blockedPeers: Set<PeerID> = []
|
||||
var trackedPackets: [BitchatPacket] = []
|
||||
var quotaReservations: [Int] = []
|
||||
var saveCalls: [(data: Data, preferredName: String?, subdirectory: String, fallbackExtension: String?, defaultPrefix: String)] = []
|
||||
var receiptStates: [String: BLEPrivateMediaReceiptState] = [:]
|
||||
var receiptCommits: [(messageID: String, storedURL: URL)] = []
|
||||
var receiptCommitSucceeds = true
|
||||
var removedIncomingFiles: [URL] = []
|
||||
var lastSeenUpdates: [PeerID] = []
|
||||
var deliveryAcks: [(messageID: String, peerID: PeerID)] = []
|
||||
var deliveredMessages: [BitchatMessage] = []
|
||||
var saveOverride: ((
|
||||
_ data: Data,
|
||||
_ preferredName: String?,
|
||||
_ subdirectory: String,
|
||||
_ fallbackExtension: String?,
|
||||
_ defaultPrefix: String
|
||||
) -> URL?)?
|
||||
var receiptStateOverride: ((String) -> BLEPrivateMediaReceiptState)?
|
||||
var receiptCommitOverride: ((String, URL) -> Bool)?
|
||||
var removeIncomingFileOverride: ((URL) -> Void)?
|
||||
}
|
||||
|
||||
private let localPeerID = PeerID(str: "0102030405060708")
|
||||
@@ -46,13 +62,44 @@ struct BLEFileTransferHandlerTests {
|
||||
},
|
||||
saveIncomingFile: { data, preferredName, subdirectory, fallbackExtension, defaultPrefix in
|
||||
recorder.saveCalls.append((data, preferredName, subdirectory, fallbackExtension, defaultPrefix))
|
||||
if let saveOverride = recorder.saveOverride {
|
||||
return saveOverride(data, preferredName, subdirectory, fallbackExtension, defaultPrefix)
|
||||
}
|
||||
return recorder.saveResult
|
||||
},
|
||||
privateMediaReceiptState: { messageID in
|
||||
if let receiptStateOverride = recorder.receiptStateOverride {
|
||||
return receiptStateOverride(messageID)
|
||||
}
|
||||
return recorder.receiptStates[messageID] ?? .absent
|
||||
},
|
||||
commitPrivateMediaFile: { messageID, storedURL in
|
||||
recorder.receiptCommits.append((messageID, storedURL))
|
||||
if let receiptCommitOverride = recorder.receiptCommitOverride {
|
||||
return receiptCommitOverride(messageID, storedURL)
|
||||
}
|
||||
guard recorder.receiptCommitSucceeds else { return false }
|
||||
recorder.receiptStates[messageID] = .accepted(storedURL)
|
||||
return true
|
||||
},
|
||||
removeIncomingFile: { storedURL in
|
||||
recorder.removedIncomingFiles.append(storedURL)
|
||||
recorder.removeIncomingFileOverride?(storedURL)
|
||||
},
|
||||
isPrivateMediaSenderBlocked: { peerID in
|
||||
recorder.blockedPeers.contains(peerID)
|
||||
},
|
||||
updatePeerLastSeen: { peerID in
|
||||
recorder.lastSeenUpdates.append(peerID)
|
||||
},
|
||||
deliverMessage: { message in
|
||||
acknowledgePrivateMedia: { messageID, peerID in
|
||||
recorder.deliveryAcks.append((messageID, peerID))
|
||||
},
|
||||
deliverMessage: { message, shouldDeliver, completion in
|
||||
guard shouldDeliver() else { return }
|
||||
recorder.deliveredMessages.append(message)
|
||||
guard shouldDeliver() else { return }
|
||||
completion()
|
||||
}
|
||||
)
|
||||
return BLEFileTransferHandler(environment: environment)
|
||||
@@ -284,6 +331,7 @@ struct BLEFileTransferHandlerTests {
|
||||
#expect(recorder.lastSeenUpdates == [remotePeerID])
|
||||
#expect(recorder.deliveredMessages.count == 1)
|
||||
#expect(recorder.deliveredMessages.first?.isPrivate == true)
|
||||
#expect(recorder.deliveredMessages.first?.id.hasPrefix("media-") == false)
|
||||
// Must be explicit: BitchatMessage defaults private messages to
|
||||
// .sending, which the media views render as an in-flight send
|
||||
// (empty reveal mask, disabled reveal tap).
|
||||
@@ -291,13 +339,14 @@ struct BLEFileTransferHandlerTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func decryptedPrivateFileUsesValidationQuotaAndPrivateDeliveryWithoutRawSignature() throws {
|
||||
func bit8EncryptedPrivateFileKeepsStableIDAndAckWithoutBit9Proof() throws {
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
|
||||
let fileName = "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
|
||||
let file = BitchatFilePacket(
|
||||
fileName: "secret.jpg",
|
||||
fileName: fileName,
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "image/jpeg",
|
||||
content: content
|
||||
@@ -316,6 +365,357 @@ struct BLEFileTransferHandlerTests {
|
||||
#expect(recorder.deliveredMessages.count == 1)
|
||||
#expect(recorder.deliveredMessages.first?.isPrivate == true)
|
||||
#expect(recorder.deliveredMessages.first?.timestamp == timestamp)
|
||||
#expect(recorder.deliveredMessages.first?.id == PrivateMediaMessageIdentity.stableID(
|
||||
senderPeerID: remotePeerID,
|
||||
recipientPeerID: localPeerID,
|
||||
fileName: fileName
|
||||
))
|
||||
#expect(recorder.receiptCommits.count == 1)
|
||||
#expect(recorder.deliveryAcks.count == 1)
|
||||
#expect(recorder.deliveryAcks.first?.messageID == recorder.deliveredMessages.first?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
func rawLegacyPrivateFileWithRetryShapedNameNeverUsesReceiptLedger() throws {
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(
|
||||
remotePeerID,
|
||||
nickname: "Alice",
|
||||
isVerified: true,
|
||||
signingPublicKey: sampleSigningKey
|
||||
)]
|
||||
recorder.signatureVerifies = true
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let content = Data([0xFF, 0xD8, 0xFF, 0xD9])
|
||||
let packet = try makeFileTransferPacket(
|
||||
sender: remotePeerID,
|
||||
mimeType: "image/jpeg",
|
||||
content: content,
|
||||
recipientID: Data(hexString: localPeerID.id),
|
||||
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
|
||||
)
|
||||
|
||||
#expect(handler.handle(packet, from: remotePeerID))
|
||||
#expect(recorder.receiptCommits.isEmpty)
|
||||
#expect(recorder.deliveryAcks.isEmpty)
|
||||
#expect(recorder.deliveredMessages.count == 1)
|
||||
#expect(recorder.deliveredMessages.first?.id.hasPrefix("media-") == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func repeatedLegacyPrivateImageNamesKeepDistinctRandomMessageIDs() throws {
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
|
||||
let file = BitchatFilePacket(
|
||||
fileName: "photo.jpg",
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "image/jpeg",
|
||||
content: content
|
||||
)
|
||||
let payload = try #require(file.encode())
|
||||
|
||||
#expect(handler.handlePrivatePayload(
|
||||
payload,
|
||||
from: remotePeerID,
|
||||
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||
))
|
||||
#expect(handler.handlePrivatePayload(
|
||||
payload,
|
||||
from: remotePeerID,
|
||||
timestamp: Date(timeIntervalSince1970: 1_235)
|
||||
))
|
||||
|
||||
#expect(recorder.deliveredMessages.count == 2)
|
||||
#expect(recorder.deliveredMessages[0].id != recorder.deliveredMessages[1].id)
|
||||
#expect(recorder.deliveredMessages.allSatisfy { !$0.id.hasPrefix("media-") })
|
||||
}
|
||||
|
||||
@Test
|
||||
func lostCapabilityProofThenStableRetryReusesDurableIDWithoutSecondDiskWrite() throws {
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
|
||||
let fileName = "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
|
||||
let file = BitchatFilePacket(
|
||||
fileName: fileName,
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "image/jpeg",
|
||||
content: content
|
||||
)
|
||||
let payload = try #require(file.encode())
|
||||
let expectedID = try #require(PrivateMediaMessageIdentity.stableID(
|
||||
senderPeerID: remotePeerID,
|
||||
recipientPeerID: localPeerID,
|
||||
fileName: fileName
|
||||
))
|
||||
|
||||
// First encrypted arrival may precede the sender's authenticated bit-9
|
||||
// proof. It still uses the bit-8 stable ID/ACK contract.
|
||||
#expect(handler.handlePrivatePayload(
|
||||
payload,
|
||||
from: remotePeerID,
|
||||
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||
))
|
||||
// A later automatic retry after proof must resolve the same durable ID
|
||||
// rather than create a legacy random-ID bubble.
|
||||
#expect(handler.handlePrivatePayload(
|
||||
payload,
|
||||
from: remotePeerID,
|
||||
timestamp: Date(timeIntervalSince1970: 1_235)
|
||||
))
|
||||
|
||||
#expect(recorder.quotaReservations == [content.count])
|
||||
#expect(recorder.saveCalls.count == 1)
|
||||
// The handler re-offers a durable duplicate so a relaunched UI can
|
||||
// restore its bubble; the synchronous conversation sink deduplicates.
|
||||
#expect(recorder.deliveredMessages.count == 2)
|
||||
#expect(recorder.lastSeenUpdates == [remotePeerID, remotePeerID])
|
||||
#expect(recorder.deliveryAcks.count == 2)
|
||||
#expect(recorder.deliveryAcks.allSatisfy {
|
||||
$0.messageID == expectedID && $0.peerID == remotePeerID
|
||||
})
|
||||
}
|
||||
|
||||
@Test
|
||||
func acceptedPrivateMediaAfterRelaunchRedeliversDurableURLBeforeAck() throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(
|
||||
"private-media-handler-relaunch-\(UUID().uuidString)",
|
||||
isDirectory: true
|
||||
)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let store = BLEIncomingFileStore(baseDirectory: root)
|
||||
let content = Data([0xFF, 0xD8, 0xFF, 0xD9])
|
||||
let file = BitchatFilePacket(
|
||||
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg",
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "image/jpeg",
|
||||
content: content
|
||||
)
|
||||
let payload = try #require(file.encode())
|
||||
|
||||
func configure(_ recorder: Recorder) {
|
||||
recorder.peers = [remotePeerID: makePeerInfo(
|
||||
remotePeerID,
|
||||
nickname: "Alice",
|
||||
isVerified: true
|
||||
)]
|
||||
recorder.saveOverride = {
|
||||
data,
|
||||
preferredName,
|
||||
subdirectory,
|
||||
fallbackExtension,
|
||||
defaultPrefix in
|
||||
store.save(
|
||||
data: data,
|
||||
preferredName: preferredName,
|
||||
subdirectory: subdirectory,
|
||||
fallbackExtension: fallbackExtension,
|
||||
defaultPrefix: defaultPrefix
|
||||
)
|
||||
}
|
||||
recorder.receiptStateOverride = {
|
||||
store.privateMediaReceiptState(messageID: $0)
|
||||
}
|
||||
recorder.receiptCommitOverride = {
|
||||
store.commitPrivateMediaFile(messageID: $0, storedURL: $1)
|
||||
}
|
||||
recorder.removeIncomingFileOverride = {
|
||||
store.removeIncomingFile(at: $0)
|
||||
}
|
||||
}
|
||||
|
||||
let first = Recorder()
|
||||
configure(first)
|
||||
#expect(makeHandler(recorder: first).handlePrivatePayload(
|
||||
payload,
|
||||
from: remotePeerID,
|
||||
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||
))
|
||||
let originalMessage = try #require(first.deliveredMessages.first)
|
||||
#expect(first.deliveryAcks.count == 1)
|
||||
|
||||
// A fresh handler models process relaunch: its in-memory reservation
|
||||
// cache is empty, so only the durable receipt can suppress disk work.
|
||||
let relaunched = Recorder()
|
||||
configure(relaunched)
|
||||
#expect(makeHandler(recorder: relaunched).handlePrivatePayload(
|
||||
payload,
|
||||
from: remotePeerID,
|
||||
timestamp: Date(timeIntervalSince1970: 1_235)
|
||||
))
|
||||
|
||||
#expect(relaunched.quotaReservations.isEmpty)
|
||||
#expect(relaunched.saveCalls.isEmpty)
|
||||
#expect(relaunched.receiptCommits.isEmpty)
|
||||
#expect(relaunched.deliveredMessages.count == 1)
|
||||
#expect(relaunched.deliveredMessages.first?.id == originalMessage.id)
|
||||
#expect(relaunched.deliveredMessages.first?.content == originalMessage.content)
|
||||
#expect(relaunched.deliveryAcks.count == 1)
|
||||
#expect(relaunched.deliveryAcks.first?.messageID == originalMessage.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
func inFlightStableDuplicateIsNotAcknowledgedAndFailedSaveRemainsRetryable() throws {
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
|
||||
let file = BitchatFilePacket(
|
||||
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg",
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "image/jpeg",
|
||||
content: content
|
||||
)
|
||||
let payload = try #require(file.encode())
|
||||
var handler: BLEFileTransferHandler!
|
||||
var nestedResult: Bool?
|
||||
var failFirstSave = true
|
||||
recorder.saveOverride = { _, _, _, _, _ in
|
||||
if failFirstSave {
|
||||
failFirstSave = false
|
||||
nestedResult = handler.handlePrivatePayload(
|
||||
payload,
|
||||
from: self.remotePeerID,
|
||||
timestamp: Date(timeIntervalSince1970: 1_235)
|
||||
)
|
||||
return nil
|
||||
}
|
||||
return recorder.saveResult
|
||||
}
|
||||
handler = makeHandler(recorder: recorder)
|
||||
|
||||
// The nested arrival sees the first reservation as pending. It is
|
||||
// coalesced without an ACK; then the first durable save fails.
|
||||
#expect(!handler.handlePrivatePayload(
|
||||
payload,
|
||||
from: remotePeerID,
|
||||
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||
))
|
||||
#expect(nestedResult == true)
|
||||
#expect(recorder.saveCalls.count == 1)
|
||||
#expect(recorder.deliveryAcks.isEmpty)
|
||||
#expect(recorder.deliveredMessages.isEmpty)
|
||||
|
||||
// Failure released the reservation, so the sender's later retry can
|
||||
// persist and deliver normally.
|
||||
#expect(handler.handlePrivatePayload(
|
||||
payload,
|
||||
from: remotePeerID,
|
||||
timestamp: Date(timeIntervalSince1970: 1_236)
|
||||
))
|
||||
#expect(recorder.saveCalls.count == 2)
|
||||
#expect(recorder.deliveryAcks.count == 1)
|
||||
#expect(recorder.deliveredMessages.count == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func unavailableDurableReceiptStateWithholdsDiskDeliveryAndAck() throws {
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(
|
||||
remotePeerID,
|
||||
nickname: "Alice",
|
||||
isVerified: true
|
||||
)]
|
||||
let fileName =
|
||||
"img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
|
||||
let messageID = try #require(PrivateMediaMessageIdentity.stableID(
|
||||
senderPeerID: remotePeerID,
|
||||
recipientPeerID: localPeerID,
|
||||
fileName: fileName
|
||||
))
|
||||
recorder.receiptStates[messageID] = .unavailable
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let content = Data([0xFF, 0xD8, 0xFF, 0xD9])
|
||||
let payload = try #require(BitchatFilePacket(
|
||||
fileName: fileName,
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "image/jpeg",
|
||||
content: content
|
||||
).encode())
|
||||
|
||||
#expect(handler.handlePrivatePayload(
|
||||
payload,
|
||||
from: remotePeerID,
|
||||
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||
))
|
||||
#expect(recorder.quotaReservations.isEmpty)
|
||||
#expect(recorder.saveCalls.isEmpty)
|
||||
#expect(recorder.receiptCommits.isEmpty)
|
||||
#expect(recorder.deliveredMessages.isEmpty)
|
||||
#expect(recorder.deliveryAcks.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func durableReceiptCommitFailureRollsBackAndWithholdsDeliveryAck() throws {
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(
|
||||
remotePeerID,
|
||||
nickname: "Alice",
|
||||
isVerified: true
|
||||
)]
|
||||
recorder.receiptCommitSucceeds = false
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let content = Data([0xFF, 0xD8, 0xFF, 0xD9])
|
||||
let payload = try #require(BitchatFilePacket(
|
||||
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg",
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "image/jpeg",
|
||||
content: content
|
||||
).encode())
|
||||
|
||||
#expect(!handler.handlePrivatePayload(
|
||||
payload,
|
||||
from: remotePeerID,
|
||||
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||
))
|
||||
#expect(recorder.saveCalls.count == 1)
|
||||
#expect(recorder.receiptCommits.count == 1)
|
||||
#expect(recorder.removedIncomingFiles.count == 1)
|
||||
#expect(recorder.removedIncomingFiles.first == recorder.saveResult)
|
||||
#expect(recorder.deliveredMessages.isEmpty)
|
||||
#expect(recorder.deliveryAcks.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func blockedPrivateMediaIsDroppedBeforeQuotaDiskAndDedupState() throws {
|
||||
let recorder = Recorder()
|
||||
recorder.blockedPeers = [remotePeerID]
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
|
||||
let file = BitchatFilePacket(
|
||||
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg",
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "image/jpeg",
|
||||
content: content
|
||||
)
|
||||
let payload = try #require(file.encode())
|
||||
|
||||
#expect(handler.handlePrivatePayload(
|
||||
payload,
|
||||
from: remotePeerID,
|
||||
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||
))
|
||||
|
||||
#expect(recorder.quotaReservations.isEmpty)
|
||||
#expect(recorder.saveCalls.isEmpty)
|
||||
#expect(recorder.lastSeenUpdates.isEmpty)
|
||||
#expect(recorder.deliveryAcks.isEmpty)
|
||||
#expect(recorder.deliveredMessages.isEmpty)
|
||||
|
||||
// Unblocking must allow a retry through; the blocked attempt cannot
|
||||
// poison the stable-ID dedup reservation.
|
||||
recorder.blockedPeers = []
|
||||
#expect(handler.handlePrivatePayload(
|
||||
payload,
|
||||
from: remotePeerID,
|
||||
timestamp: Date(timeIntervalSince1970: 1_235)
|
||||
))
|
||||
#expect(recorder.saveCalls.count == 1)
|
||||
#expect(recorder.deliveredMessages.count == 1)
|
||||
#expect(recorder.deliveryAcks.count == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -337,6 +737,7 @@ struct BLEFileTransferHandlerTests {
|
||||
#expect(recorder.quotaReservations.isEmpty)
|
||||
#expect(recorder.saveCalls.isEmpty)
|
||||
#expect(recorder.lastSeenUpdates.isEmpty)
|
||||
#expect(recorder.deliveryAcks.isEmpty)
|
||||
#expect(recorder.deliveredMessages.isEmpty)
|
||||
}
|
||||
|
||||
@@ -468,6 +869,33 @@ struct BLEFileTransferHandlerTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func panicWipeClearsCachedPrivateMediaReceiptDecisions() throws {
|
||||
let base = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(
|
||||
"panic-receipt-cache-\(UUID().uuidString)",
|
||||
isDirectory: true
|
||||
)
|
||||
defer { try? FileManager.default.removeItem(at: base) }
|
||||
let messageID = "media-00112233445566778899aabbccddeeff"
|
||||
|
||||
let seed = BLEPrivateMediaReceiptStore(baseDirectory: base)
|
||||
#expect(seed.recordDeleted(messageID: messageID))
|
||||
|
||||
let store = BLEIncomingFileStore(baseDirectory: base)
|
||||
#expect(
|
||||
store.privateMediaReceiptState(messageID: messageID)
|
||||
== .tombstoned
|
||||
)
|
||||
|
||||
try store.panicWipe()
|
||||
|
||||
#expect(
|
||||
store.privateMediaReceiptState(messageID: messageID)
|
||||
== .absent
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func panicWipeAttemptsDeletionWhenMarkerPersistenceFails() throws {
|
||||
enum MarkerFailure: Error { case unavailable }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import BitFoundation
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
@@ -11,7 +12,11 @@ struct BLENoisePacketHandlerTests {
|
||||
var handshakeAuthenticated = false
|
||||
var hasSession = false
|
||||
let sessionGeneration = UUID()
|
||||
var awaitingResponderHandshake = false
|
||||
var decryptResult: Result<Data, Error> = .success(Data())
|
||||
var currentDate = Date(timeIntervalSince1970: 1_000)
|
||||
var transportGenerationReady = false
|
||||
var forcedServiceDecryptError: Error?
|
||||
|
||||
var processedHandshakes: [(peerID: PeerID, message: Data)] = []
|
||||
var hasSessionQueries: [PeerID] = []
|
||||
@@ -34,11 +39,12 @@ struct BLENoisePacketHandlerTests {
|
||||
recorder: Recorder,
|
||||
now: Date = Date(timeIntervalSince1970: 1_000)
|
||||
) -> BLENoisePacketHandler {
|
||||
recorder.currentDate = now
|
||||
let environment = BLENoisePacketHandlerEnvironment(
|
||||
localPeerID: { [localPeerID] in localPeerID },
|
||||
localPeerIDData: { [localPeerIDData] in localPeerIDData },
|
||||
messageTTL: TransportConfig.messageTTLDefault,
|
||||
now: { now },
|
||||
now: { recorder.currentDate },
|
||||
processHandshakeMessage: { peerID, message in
|
||||
recorder.processedHandshakes.append((peerID, message))
|
||||
return NoiseHandshakeProcessingResult(
|
||||
@@ -51,6 +57,9 @@ struct BLENoisePacketHandlerTests {
|
||||
recorder.hasSessionQueries.append(peerID)
|
||||
return recorder.hasSession
|
||||
},
|
||||
isAwaitingResponderHandshakeCompletion: { _ in
|
||||
recorder.awaitingResponderHandshake
|
||||
},
|
||||
initiateHandshake: { peerID in
|
||||
recorder.initiatedHandshakes.append(peerID)
|
||||
recorder.events.append("initiateHandshake")
|
||||
@@ -82,6 +91,120 @@ struct BLENoisePacketHandlerTests {
|
||||
return BLENoisePacketHandler(environment: environment)
|
||||
}
|
||||
|
||||
private func makeServiceBackedHandler(
|
||||
service: NoiseEncryptionService,
|
||||
localPeerID: PeerID,
|
||||
recorder: Recorder,
|
||||
transportGenerationIsReady:
|
||||
@escaping (UUID) -> Bool
|
||||
) -> BLENoisePacketHandler {
|
||||
BLENoisePacketHandler(
|
||||
environment: BLENoisePacketHandlerEnvironment(
|
||||
localPeerID: { localPeerID },
|
||||
localPeerIDData: {
|
||||
Data(hexString: localPeerID.id) ?? Data()
|
||||
},
|
||||
messageTTL: TransportConfig.messageTTLDefault,
|
||||
now: { recorder.currentDate },
|
||||
processHandshakeMessage: { peerID, message in
|
||||
try service.processHandshakeMessageWithResult(
|
||||
from: peerID,
|
||||
message: message
|
||||
)
|
||||
},
|
||||
hasNoiseSession: { peerID in
|
||||
service.hasSession(with: peerID)
|
||||
},
|
||||
isAwaitingResponderHandshakeCompletion: { peerID in
|
||||
service.isAwaitingResponderHandshakeCompletion(
|
||||
with: peerID
|
||||
)
|
||||
},
|
||||
initiateHandshake: { peerID in
|
||||
recorder.initiatedHandshakes.append(peerID)
|
||||
},
|
||||
broadcastPacket: { packet in
|
||||
recorder.broadcastPackets.append(packet)
|
||||
},
|
||||
updatePeerLastSeen: { peerID in
|
||||
recorder.lastSeenUpdates.append(peerID)
|
||||
},
|
||||
decrypt: { payload, peerID in
|
||||
recorder.decryptCalls.append((payload, peerID))
|
||||
if let error = recorder.forcedServiceDecryptError {
|
||||
throw error
|
||||
}
|
||||
let result =
|
||||
try service.decryptWithSessionGeneration(
|
||||
payload,
|
||||
from: peerID,
|
||||
establishedGenerationIsReady:
|
||||
transportGenerationIsReady
|
||||
)
|
||||
return BLENoiseDecryptionResult(
|
||||
plaintext: result.plaintext,
|
||||
sessionGeneration: result.sessionGeneration
|
||||
)
|
||||
},
|
||||
clearSession: { peerID in
|
||||
recorder.clearedSessions.append(peerID)
|
||||
service.clearSession(for: peerID)
|
||||
},
|
||||
handleAuthenticatedPeerState: {
|
||||
peerID, payload, generation in
|
||||
recorder.authenticatedPeerStates.append(
|
||||
(peerID, payload, generation)
|
||||
)
|
||||
},
|
||||
deliverNoisePayload: {
|
||||
peerID, type, payload, timestamp in
|
||||
recorder.deliveries.append(
|
||||
(peerID, type, payload, timestamp)
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func establishedServices() throws -> (
|
||||
sender: NoiseEncryptionService,
|
||||
receiver: NoiseEncryptionService,
|
||||
senderPeerID: PeerID,
|
||||
receiverPeerID: PeerID
|
||||
) {
|
||||
let sender = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let receiver = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let senderPeerID = PeerID(
|
||||
publicKey: sender.getStaticPublicKeyData()
|
||||
)
|
||||
let receiverPeerID = PeerID(
|
||||
publicKey: receiver.getStaticPublicKeyData()
|
||||
)
|
||||
let message1 = try sender.initiateHandshake(with: receiverPeerID)
|
||||
let message2 = try #require(
|
||||
try receiver.processHandshakeMessage(
|
||||
from: senderPeerID,
|
||||
message: message1
|
||||
)
|
||||
)
|
||||
let message3 = try #require(
|
||||
try sender.processHandshakeMessage(
|
||||
from: receiverPeerID,
|
||||
message: message2
|
||||
)
|
||||
)
|
||||
_ = try receiver.processHandshakeMessage(
|
||||
from: senderPeerID,
|
||||
message: message3
|
||||
)
|
||||
return (
|
||||
sender,
|
||||
receiver,
|
||||
senderPeerID,
|
||||
receiverPeerID
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Handshake
|
||||
|
||||
@Test
|
||||
@@ -198,6 +321,24 @@ struct BLENoisePacketHandlerTests {
|
||||
#expect(recorder.broadcastPackets.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func managedHandshakeFailureDoesNotStartASecondRecovery() {
|
||||
let recorder = Recorder()
|
||||
recorder.handshakeResult = .failure(
|
||||
NoiseManagedHandshakeFailure(underlying: TestError())
|
||||
)
|
||||
recorder.hasSession = false
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = makeHandshakePacket(
|
||||
recipientID: Data(hexString: localPeerID.id)
|
||||
)
|
||||
|
||||
#expect(!handler.handleHandshake(packet, from: remotePeerID))
|
||||
#expect(recorder.hasSessionQueries.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
#expect(recorder.broadcastPackets.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: Encrypted
|
||||
|
||||
@Test
|
||||
@@ -346,6 +487,799 @@ struct BLENoisePacketHandlerTests {
|
||||
#expect(recorder.deliveries.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func earlyCiphertextIsRetriedAfterResponderHandshakeCompletes() {
|
||||
let recorder = Recorder()
|
||||
recorder.hasSession = true
|
||||
recorder.awaitingResponderHandshake = true
|
||||
recorder.decryptResult = .failure(
|
||||
CryptoKitError.authenticationFailure
|
||||
)
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let encrypted = makeEncryptedPacket(
|
||||
recipientID: Data(hexString: localPeerID.id)
|
||||
)
|
||||
|
||||
handler.handleEncrypted(encrypted, from: remotePeerID)
|
||||
|
||||
#expect(recorder.decryptCalls.count == 1)
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
|
||||
recorder.awaitingResponderHandshake = false
|
||||
recorder.decryptResult = .success(
|
||||
Data([NoisePayloadType.privateMessage.rawValue, 0xCA, 0xFE])
|
||||
)
|
||||
handler.handleSessionAuthenticated(remotePeerID)
|
||||
handler.handleSessionAuthenticated(remotePeerID)
|
||||
|
||||
#expect(recorder.decryptCalls.count == 2)
|
||||
#expect(recorder.deliveries.count == 1)
|
||||
#expect(recorder.deliveries.first?.type == .privateMessage)
|
||||
#expect(recorder.deliveries.first?.payload == Data([0xCA, 0xFE]))
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func panicResetDiscardsDeferredCiphertextBeforeFutureAuthentication() {
|
||||
let recorder = Recorder()
|
||||
recorder.hasSession = true
|
||||
recorder.awaitingResponderHandshake = true
|
||||
recorder.decryptResult = .failure(
|
||||
CryptoKitError.authenticationFailure
|
||||
)
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let prePanicCiphertext = makeEncryptedPacket(
|
||||
recipientID: Data(hexString: localPeerID.id),
|
||||
payload: Data(
|
||||
count: NoiseSecurityConstants.maxMessageSize
|
||||
+ NoiseSecurityConstants.transportCiphertextOverhead
|
||||
)
|
||||
)
|
||||
|
||||
handler.handleEncrypted(prePanicCiphertext, from: remotePeerID)
|
||||
#expect(recorder.decryptCalls.count == 1)
|
||||
|
||||
handler.resetForPanic()
|
||||
|
||||
// Three maximum-sized packets fit only when reset also zeroed the
|
||||
// global byte accounting. They model ciphertext received under the
|
||||
// replacement identity before that responder handshake completes.
|
||||
for index in 0..<3 {
|
||||
handler.handleEncrypted(
|
||||
makeEncryptedPacket(
|
||||
recipientID: Data(hexString: localPeerID.id),
|
||||
timestamp: UInt64(901_000 + index),
|
||||
payload: Data(
|
||||
count: NoiseSecurityConstants.maxMessageSize
|
||||
+ NoiseSecurityConstants.transportCiphertextOverhead
|
||||
)
|
||||
),
|
||||
from: remotePeerID
|
||||
)
|
||||
}
|
||||
#expect(recorder.decryptCalls.count == 4)
|
||||
|
||||
recorder.awaitingResponderHandshake = false
|
||||
recorder.decryptResult = .success(
|
||||
Data([NoisePayloadType.privateMessage.rawValue, 0xCA, 0xFE])
|
||||
)
|
||||
handler.handleSessionAuthenticated(remotePeerID)
|
||||
|
||||
// Only the three post-reset packets replay; the pre-panic packet does
|
||||
// not survive into the replacement session.
|
||||
#expect(recorder.decryptCalls.count == 7)
|
||||
#expect(recorder.deliveries.count == 3)
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func ciphertextQueuedAheadOfEstablishmentCallbackDoesNotConsumeNonce()
|
||||
throws {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let alicePeerID = PeerID(
|
||||
publicKey: alice.getStaticPublicKeyData()
|
||||
)
|
||||
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
||||
|
||||
let message1 = try alice.initiateHandshake(with: bobPeerID)
|
||||
let message2 = try #require(
|
||||
try bob.processHandshakeMessage(
|
||||
from: alicePeerID,
|
||||
message: message1
|
||||
)
|
||||
)
|
||||
let message3 = try #require(
|
||||
try alice.processHandshakeMessage(
|
||||
from: bobPeerID,
|
||||
message: message2
|
||||
)
|
||||
)
|
||||
let typedPayload = Data([
|
||||
NoisePayloadType.privateMessage.rawValue,
|
||||
0xCA, 0xFE
|
||||
])
|
||||
let ciphertext = try alice.encrypt(
|
||||
typedPayload,
|
||||
for: bobPeerID
|
||||
)
|
||||
|
||||
// Manager promotion has completed, but the serialized BLE callback is
|
||||
// deliberately still behind this ciphertext.
|
||||
_ = try bob.processHandshakeMessage(
|
||||
from: alicePeerID,
|
||||
message: message3
|
||||
)
|
||||
let recorder = Recorder()
|
||||
recorder.transportGenerationReady = false
|
||||
let handler = makeServiceBackedHandler(
|
||||
service: bob,
|
||||
localPeerID: bobPeerID,
|
||||
recorder: recorder,
|
||||
transportGenerationIsReady: { _ in
|
||||
recorder.transportGenerationReady
|
||||
}
|
||||
)
|
||||
let packet = makeEncryptedPacket(
|
||||
recipientID: Data(hexString: bobPeerID.id),
|
||||
payload: ciphertext
|
||||
)
|
||||
|
||||
handler.handleEncrypted(packet, from: alicePeerID)
|
||||
#expect(recorder.deliveries.isEmpty)
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
|
||||
// The exact ciphertext must still authenticate, proving the readiness
|
||||
// rejection happened before the receive nonce was consumed.
|
||||
recorder.transportGenerationReady = true
|
||||
handler.handleSessionAuthenticated(alicePeerID)
|
||||
|
||||
#expect(recorder.decryptCalls.count == 2)
|
||||
#expect(recorder.deliveries.count == 1)
|
||||
#expect(recorder.deliveries.first?.type == .privateMessage)
|
||||
#expect(recorder.deliveries.first?.payload == Data([0xCA, 0xFE]))
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func ciphertextQueuedAheadOfRestoreCallbackDoesNotConsumeNonce()
|
||||
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())
|
||||
|
||||
let initial1 = try alice.initiateHandshake(with: bobPeerID)
|
||||
let initial2 = try #require(
|
||||
try bob.processHandshakeMessage(
|
||||
from: alicePeerID,
|
||||
message: initial1
|
||||
)
|
||||
)
|
||||
let initial3 = try #require(
|
||||
try alice.processHandshakeMessage(
|
||||
from: bobPeerID,
|
||||
message: initial2
|
||||
)
|
||||
)
|
||||
_ = try bob.processHandshakeMessage(
|
||||
from: alicePeerID,
|
||||
message: initial3
|
||||
)
|
||||
let typedPayload = Data([
|
||||
NoisePayloadType.privateMessage.rawValue,
|
||||
0xBE, 0xEF
|
||||
])
|
||||
let delayedCiphertext = try alice.encrypt(
|
||||
typedPayload,
|
||||
for: bobPeerID
|
||||
)
|
||||
|
||||
let forged1 = try mallory.initiateHandshake(with: bobPeerID)
|
||||
let forged2 = try #require(
|
||||
try bob.processHandshakeMessage(
|
||||
from: alicePeerID,
|
||||
message: forged1
|
||||
)
|
||||
)
|
||||
let forged3 = try #require(
|
||||
try mallory.processHandshakeMessage(
|
||||
from: bobPeerID,
|
||||
message: forged2
|
||||
)
|
||||
)
|
||||
#expect(throws: NoiseSessionError.peerIdentityMismatch) {
|
||||
try bob.processHandshakeMessage(
|
||||
from: alicePeerID,
|
||||
message: forged3
|
||||
)
|
||||
}
|
||||
#expect(bob.hasEstablishedSession(with: alicePeerID))
|
||||
|
||||
let recorder = Recorder()
|
||||
recorder.transportGenerationReady = false
|
||||
let handler = makeServiceBackedHandler(
|
||||
service: bob,
|
||||
localPeerID: bobPeerID,
|
||||
recorder: recorder,
|
||||
transportGenerationIsReady: { _ in
|
||||
recorder.transportGenerationReady
|
||||
}
|
||||
)
|
||||
let packet = makeEncryptedPacket(
|
||||
recipientID: Data(hexString: bobPeerID.id),
|
||||
payload: delayedCiphertext
|
||||
)
|
||||
|
||||
// Manager rollback is visible, while the BLE restore callback is
|
||||
// deliberately still queued behind this ciphertext.
|
||||
handler.handleEncrypted(packet, from: alicePeerID)
|
||||
#expect(recorder.deliveries.isEmpty)
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
|
||||
recorder.transportGenerationReady = true
|
||||
handler.handleSessionAuthenticated(alicePeerID)
|
||||
|
||||
#expect(recorder.decryptCalls.count == 2)
|
||||
#expect(recorder.deliveries.count == 1)
|
||||
#expect(recorder.deliveries.first?.type == .privateMessage)
|
||||
#expect(recorder.deliveries.first?.payload == Data([0xBE, 0xEF]))
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func oversizedCiphertextCannotEvictEstablishedTransport() throws {
|
||||
let pair = try establishedServices()
|
||||
let recorder = Recorder()
|
||||
recorder.transportGenerationReady = true
|
||||
let handler = makeServiceBackedHandler(
|
||||
service: pair.receiver,
|
||||
localPeerID: pair.receiverPeerID,
|
||||
recorder: recorder,
|
||||
transportGenerationIsReady: { _ in
|
||||
recorder.transportGenerationReady
|
||||
}
|
||||
)
|
||||
|
||||
handler.handleEncrypted(
|
||||
makeEncryptedPacket(
|
||||
recipientID: Data(hexString: pair.receiverPeerID.id),
|
||||
payload: Data(
|
||||
count:
|
||||
NoiseSecurityConstants
|
||||
.maxPrivateFileCiphertextSize + 1
|
||||
)
|
||||
),
|
||||
from: pair.senderPeerID
|
||||
)
|
||||
#expect(
|
||||
pair.receiver.hasEstablishedSession(with: pair.senderPeerID)
|
||||
)
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
|
||||
let valid = try pair.sender.encrypt(
|
||||
Data([NoisePayloadType.privateMessage.rawValue, 0x01]),
|
||||
for: pair.receiverPeerID
|
||||
)
|
||||
handler.handleEncrypted(
|
||||
makeEncryptedPacket(
|
||||
recipientID: Data(hexString: pair.receiverPeerID.id),
|
||||
payload: valid
|
||||
),
|
||||
from: pair.senderPeerID
|
||||
)
|
||||
|
||||
#expect(recorder.deliveries.count == 1)
|
||||
#expect(recorder.deliveries.first?.payload == Data([0x01]))
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func forgedAuthenticationFailureCannotEvictEstablishedTransport()
|
||||
throws {
|
||||
let pair = try establishedServices()
|
||||
let recorder = Recorder()
|
||||
recorder.transportGenerationReady = true
|
||||
let handler = makeServiceBackedHandler(
|
||||
service: pair.receiver,
|
||||
localPeerID: pair.receiverPeerID,
|
||||
recorder: recorder,
|
||||
transportGenerationIsReady: { _ in
|
||||
recorder.transportGenerationReady
|
||||
}
|
||||
)
|
||||
let valid = try pair.sender.encrypt(
|
||||
Data([NoisePayloadType.privateMessage.rawValue, 0x02]),
|
||||
for: pair.receiverPeerID
|
||||
)
|
||||
var forged = valid
|
||||
forged[forged.index(before: forged.endIndex)] ^= 0xFF
|
||||
|
||||
handler.handleEncrypted(
|
||||
makeEncryptedPacket(
|
||||
recipientID: Data(hexString: pair.receiverPeerID.id),
|
||||
payload: forged
|
||||
),
|
||||
from: pair.senderPeerID
|
||||
)
|
||||
#expect(
|
||||
pair.receiver.hasEstablishedSession(with: pair.senderPeerID)
|
||||
)
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
|
||||
// Authentication failure leaves nonce state untouched, so the exact
|
||||
// original ciphertext remains valid.
|
||||
handler.handleEncrypted(
|
||||
makeEncryptedPacket(
|
||||
recipientID: Data(hexString: pair.receiverPeerID.id),
|
||||
payload: valid
|
||||
),
|
||||
from: pair.senderPeerID
|
||||
)
|
||||
|
||||
#expect(recorder.deliveries.count == 1)
|
||||
#expect(recorder.deliveries.first?.payload == Data([0x02]))
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func replayCannotEvictEstablishedTransportOrBlockNextNonce() throws {
|
||||
let pair = try establishedServices()
|
||||
let recorder = Recorder()
|
||||
recorder.transportGenerationReady = true
|
||||
let handler = makeServiceBackedHandler(
|
||||
service: pair.receiver,
|
||||
localPeerID: pair.receiverPeerID,
|
||||
recorder: recorder,
|
||||
transportGenerationIsReady: { _ in
|
||||
recorder.transportGenerationReady
|
||||
}
|
||||
)
|
||||
let first = try pair.sender.encrypt(
|
||||
Data([NoisePayloadType.privateMessage.rawValue, 0x03]),
|
||||
for: pair.receiverPeerID
|
||||
)
|
||||
let firstPacket = makeEncryptedPacket(
|
||||
recipientID: Data(hexString: pair.receiverPeerID.id),
|
||||
payload: first
|
||||
)
|
||||
|
||||
handler.handleEncrypted(firstPacket, from: pair.senderPeerID)
|
||||
handler.handleEncrypted(firstPacket, from: pair.senderPeerID)
|
||||
#expect(
|
||||
pair.receiver.hasEstablishedSession(with: pair.senderPeerID)
|
||||
)
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
|
||||
let next = try pair.sender.encrypt(
|
||||
Data([NoisePayloadType.privateMessage.rawValue, 0x04]),
|
||||
for: pair.receiverPeerID
|
||||
)
|
||||
handler.handleEncrypted(
|
||||
makeEncryptedPacket(
|
||||
recipientID: Data(hexString: pair.receiverPeerID.id),
|
||||
payload: next
|
||||
),
|
||||
from: pair.senderPeerID
|
||||
)
|
||||
|
||||
#expect(recorder.deliveries.count == 2)
|
||||
#expect(recorder.deliveries.map { $0.payload } == [
|
||||
Data([0x03]), Data([0x04])
|
||||
])
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func rateLimitFailureCannotEvictEstablishedTransportOrConsumeNonce()
|
||||
throws {
|
||||
let pair = try establishedServices()
|
||||
let recorder = Recorder()
|
||||
recorder.transportGenerationReady = true
|
||||
recorder.forcedServiceDecryptError =
|
||||
NoiseSecurityError.rateLimitExceeded
|
||||
let handler = makeServiceBackedHandler(
|
||||
service: pair.receiver,
|
||||
localPeerID: pair.receiverPeerID,
|
||||
recorder: recorder,
|
||||
transportGenerationIsReady: { _ in
|
||||
recorder.transportGenerationReady
|
||||
}
|
||||
)
|
||||
let valid = try pair.sender.encrypt(
|
||||
Data([NoisePayloadType.privateMessage.rawValue, 0x05]),
|
||||
for: pair.receiverPeerID
|
||||
)
|
||||
|
||||
handler.handleEncrypted(
|
||||
makeEncryptedPacket(
|
||||
recipientID: Data(hexString: pair.receiverPeerID.id),
|
||||
payload: Data(repeating: 0xA5, count: 20)
|
||||
),
|
||||
from: pair.senderPeerID
|
||||
)
|
||||
#expect(
|
||||
pair.receiver.hasEstablishedSession(with: pair.senderPeerID)
|
||||
)
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
|
||||
recorder.forcedServiceDecryptError = nil
|
||||
handler.handleEncrypted(
|
||||
makeEncryptedPacket(
|
||||
recipientID: Data(hexString: pair.receiverPeerID.id),
|
||||
payload: valid
|
||||
),
|
||||
from: pair.senderPeerID
|
||||
)
|
||||
|
||||
#expect(recorder.deliveries.count == 1)
|
||||
#expect(recorder.deliveries.first?.payload == Data([0x05]))
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func maximumPrivateFileCiphertextIsEligibleForDeferredRetry() {
|
||||
let recorder = Recorder()
|
||||
recorder.hasSession = true
|
||||
recorder.awaitingResponderHandshake = true
|
||||
recorder.decryptResult = .failure(
|
||||
CryptoKitError.authenticationFailure
|
||||
)
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let encrypted = makeEncryptedPacket(
|
||||
recipientID: Data(hexString: localPeerID.id),
|
||||
payload: Data(
|
||||
count: NoiseSecurityConstants.maxPrivateFileCiphertextSize
|
||||
)
|
||||
)
|
||||
|
||||
handler.handleEncrypted(encrypted, from: remotePeerID)
|
||||
recorder.awaitingResponderHandshake = false
|
||||
recorder.decryptResult = .success(
|
||||
Data([NoisePayloadType.privateFile.rawValue, 0x01])
|
||||
)
|
||||
handler.handleSessionAuthenticated(remotePeerID)
|
||||
|
||||
#expect(recorder.decryptCalls.count == 2)
|
||||
#expect(recorder.deliveries.count == 1)
|
||||
#expect(recorder.deliveries.first?.type == .privateFile)
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func oversizedEarlyCiphertextIsNotDeferred() {
|
||||
let recorder = Recorder()
|
||||
recorder.hasSession = true
|
||||
recorder.awaitingResponderHandshake = true
|
||||
recorder.decryptResult = .failure(
|
||||
CryptoKitError.authenticationFailure
|
||||
)
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let encrypted = makeEncryptedPacket(
|
||||
recipientID: Data(hexString: localPeerID.id),
|
||||
payload: Data(
|
||||
count:
|
||||
NoiseSecurityConstants.maxPrivateFileCiphertextSize + 1
|
||||
)
|
||||
)
|
||||
|
||||
handler.handleEncrypted(encrypted, from: remotePeerID)
|
||||
recorder.awaitingResponderHandshake = false
|
||||
recorder.decryptResult = .success(
|
||||
Data([NoisePayloadType.delivered.rawValue, 0x01])
|
||||
)
|
||||
handler.handleSessionAuthenticated(remotePeerID)
|
||||
|
||||
#expect(recorder.decryptCalls.count == 1)
|
||||
#expect(recorder.deliveries.isEmpty)
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func missingSessionCiphertextIsRetriedAfterResponderHandshakeCompletes() {
|
||||
let recorder = Recorder()
|
||||
recorder.hasSession = true
|
||||
recorder.awaitingResponderHandshake = true
|
||||
recorder.decryptResult = .failure(
|
||||
NoiseEncryptionError.sessionNotEstablished
|
||||
)
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let encrypted = makeEncryptedPacket(
|
||||
recipientID: Data(hexString: localPeerID.id)
|
||||
)
|
||||
|
||||
handler.handleEncrypted(encrypted, from: remotePeerID)
|
||||
#expect(recorder.decryptCalls.count == 1)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
|
||||
recorder.awaitingResponderHandshake = false
|
||||
recorder.decryptResult = .success(
|
||||
Data([NoisePayloadType.delivered.rawValue, 0x01])
|
||||
)
|
||||
handler.handleSessionAuthenticated(remotePeerID)
|
||||
|
||||
#expect(recorder.decryptCalls.count == 2)
|
||||
#expect(recorder.deliveries.count == 1)
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func lowNonceCiphertextIsRetriedAfterResponderHandshakeCompletes() {
|
||||
let recorder = Recorder()
|
||||
recorder.hasSession = true
|
||||
recorder.awaitingResponderHandshake = true
|
||||
recorder.decryptResult = .failure(NoiseError.replayDetected)
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let encrypted = makeEncryptedPacket(
|
||||
recipientID: Data(hexString: localPeerID.id)
|
||||
)
|
||||
|
||||
handler.handleEncrypted(encrypted, from: remotePeerID)
|
||||
recorder.awaitingResponderHandshake = false
|
||||
recorder.decryptResult = .success(
|
||||
Data([NoisePayloadType.readReceipt.rawValue, 0x02])
|
||||
)
|
||||
handler.handleSessionAuthenticated(remotePeerID)
|
||||
|
||||
#expect(recorder.decryptCalls.count == 2)
|
||||
#expect(recorder.deliveries.count == 1)
|
||||
#expect(recorder.deliveries.first?.type == .readReceipt)
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func invalidDeferredCiphertextDoesNotClearAuthenticatedSession() {
|
||||
let recorder = Recorder()
|
||||
recorder.hasSession = true
|
||||
recorder.awaitingResponderHandshake = true
|
||||
recorder.decryptResult = .failure(
|
||||
CryptoKitError.authenticationFailure
|
||||
)
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let encrypted = makeEncryptedPacket(
|
||||
recipientID: Data(hexString: localPeerID.id)
|
||||
)
|
||||
|
||||
handler.handleEncrypted(encrypted, from: remotePeerID)
|
||||
recorder.awaitingResponderHandshake = false
|
||||
handler.handleSessionAuthenticated(remotePeerID)
|
||||
|
||||
#expect(recorder.decryptCalls.count == 2)
|
||||
#expect(recorder.deliveries.isEmpty)
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func nonCipherFailureDuringResponderHandshakeIsDroppedNotDeferred() {
|
||||
let recorder = Recorder()
|
||||
recorder.hasSession = true
|
||||
recorder.awaitingResponderHandshake = true
|
||||
recorder.decryptResult = .failure(TestError())
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
handler.handleEncrypted(
|
||||
makeEncryptedPacket(
|
||||
recipientID: Data(hexString: localPeerID.id)
|
||||
),
|
||||
from: remotePeerID
|
||||
)
|
||||
|
||||
recorder.awaitingResponderHandshake = false
|
||||
recorder.decryptResult = .success(
|
||||
Data([NoisePayloadType.delivered.rawValue, 0x01])
|
||||
)
|
||||
handler.handleSessionAuthenticated(remotePeerID)
|
||||
|
||||
#expect(recorder.decryptCalls.count == 1)
|
||||
#expect(recorder.deliveries.isEmpty)
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func earlyCiphertextBufferIsBoundedPerPeer() {
|
||||
let recorder = Recorder()
|
||||
recorder.hasSession = true
|
||||
recorder.awaitingResponderHandshake = true
|
||||
recorder.decryptResult = .failure(
|
||||
CryptoKitError.authenticationFailure
|
||||
)
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
|
||||
for index in 0..<5 {
|
||||
handler.handleEncrypted(
|
||||
makeEncryptedPacket(
|
||||
recipientID: Data(hexString: localPeerID.id),
|
||||
timestamp: UInt64(900_000 + index)
|
||||
),
|
||||
from: remotePeerID
|
||||
)
|
||||
}
|
||||
#expect(recorder.decryptCalls.count == 5)
|
||||
|
||||
recorder.awaitingResponderHandshake = false
|
||||
recorder.decryptResult = .success(
|
||||
Data([NoisePayloadType.delivered.rawValue, 0x01])
|
||||
)
|
||||
handler.handleSessionAuthenticated(remotePeerID)
|
||||
|
||||
#expect(recorder.decryptCalls.count == 9)
|
||||
#expect(recorder.deliveries.count == 4)
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func earlyCiphertextBufferIsBoundedGlobally() {
|
||||
let recorder = Recorder()
|
||||
recorder.hasSession = true
|
||||
recorder.awaitingResponderHandshake = true
|
||||
recorder.decryptResult = .failure(
|
||||
CryptoKitError.authenticationFailure
|
||||
)
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let peers = (1...33).map {
|
||||
PeerID(str: String(format: "%016llx", UInt64($0)))
|
||||
}
|
||||
let packet = makeEncryptedPacket(
|
||||
recipientID: Data(hexString: localPeerID.id)
|
||||
)
|
||||
|
||||
for peerID in peers {
|
||||
handler.handleEncrypted(packet, from: peerID)
|
||||
}
|
||||
#expect(recorder.decryptCalls.count == 33)
|
||||
|
||||
recorder.awaitingResponderHandshake = false
|
||||
recorder.decryptResult = .success(
|
||||
Data([NoisePayloadType.delivered.rawValue, 0x01])
|
||||
)
|
||||
for peerID in peers {
|
||||
handler.handleSessionAuthenticated(peerID)
|
||||
}
|
||||
|
||||
#expect(recorder.decryptCalls.count == 65)
|
||||
#expect(recorder.deliveries.count == 32)
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func earlyCiphertextBufferKeepsPrivateFileRoomAndByteBound() {
|
||||
let recorder = Recorder()
|
||||
recorder.hasSession = true
|
||||
recorder.awaitingResponderHandshake = true
|
||||
recorder.decryptResult = .failure(
|
||||
CryptoKitError.authenticationFailure
|
||||
)
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let peers = [
|
||||
PeerID(str: "0000000000000001"),
|
||||
PeerID(str: "0000000000000002"),
|
||||
PeerID(str: "0000000000000003")
|
||||
]
|
||||
|
||||
handler.handleEncrypted(
|
||||
makeEncryptedPacket(
|
||||
recipientID: Data(hexString: localPeerID.id),
|
||||
payload: Data(
|
||||
count:
|
||||
NoiseSecurityConstants.maxPrivateFileCiphertextSize
|
||||
)
|
||||
),
|
||||
from: peers[0]
|
||||
)
|
||||
handler.handleEncrypted(
|
||||
makeEncryptedPacket(
|
||||
recipientID: Data(hexString: localPeerID.id),
|
||||
payload: Data(count: 256 * 1024)
|
||||
),
|
||||
from: peers[1]
|
||||
)
|
||||
handler.handleEncrypted(
|
||||
makeEncryptedPacket(
|
||||
recipientID: Data(hexString: localPeerID.id),
|
||||
payload: Data([0x01])
|
||||
),
|
||||
from: peers[2]
|
||||
)
|
||||
|
||||
recorder.awaitingResponderHandshake = false
|
||||
recorder.decryptResult = .success(
|
||||
Data([NoisePayloadType.delivered.rawValue, 0x01])
|
||||
)
|
||||
for peerID in peers {
|
||||
handler.handleSessionAuthenticated(peerID)
|
||||
}
|
||||
|
||||
#expect(recorder.decryptCalls.count == 5)
|
||||
#expect(recorder.deliveries.count == 2)
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func expiredEarlyCiphertextIsNotRetried() {
|
||||
let recorder = Recorder()
|
||||
recorder.hasSession = true
|
||||
recorder.awaitingResponderHandshake = true
|
||||
recorder.decryptResult = .failure(
|
||||
CryptoKitError.authenticationFailure
|
||||
)
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
handler.handleEncrypted(
|
||||
makeEncryptedPacket(
|
||||
recipientID: Data(hexString: localPeerID.id)
|
||||
),
|
||||
from: remotePeerID
|
||||
)
|
||||
|
||||
recorder.currentDate =
|
||||
recorder.currentDate.addingTimeInterval(
|
||||
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
|
||||
+ 0.001
|
||||
)
|
||||
recorder.awaitingResponderHandshake = false
|
||||
recorder.decryptResult = .success(
|
||||
Data([NoisePayloadType.delivered.rawValue, 0x01])
|
||||
)
|
||||
handler.handleSessionAuthenticated(remotePeerID)
|
||||
|
||||
#expect(recorder.decryptCalls.count == 1)
|
||||
#expect(recorder.deliveries.isEmpty)
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func earlyCiphertextSurvivesResponderHandshakeWindow() {
|
||||
let recorder = Recorder()
|
||||
recorder.hasSession = true
|
||||
recorder.awaitingResponderHandshake = true
|
||||
recorder.decryptResult = .failure(
|
||||
CryptoKitError.authenticationFailure
|
||||
)
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
handler.handleEncrypted(
|
||||
makeEncryptedPacket(
|
||||
recipientID: Data(hexString: localPeerID.id)
|
||||
),
|
||||
from: remotePeerID
|
||||
)
|
||||
|
||||
recorder.currentDate =
|
||||
recorder.currentDate.addingTimeInterval(
|
||||
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
|
||||
- 0.001
|
||||
)
|
||||
recorder.awaitingResponderHandshake = false
|
||||
recorder.decryptResult = .success(
|
||||
Data([NoisePayloadType.delivered.rawValue, 0x01])
|
||||
)
|
||||
handler.handleSessionAuthenticated(remotePeerID)
|
||||
|
||||
#expect(recorder.decryptCalls.count == 2)
|
||||
#expect(recorder.deliveries.count == 1)
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
private func makeHandshakePacket(recipientID: Data?) -> BitchatPacket {
|
||||
BitchatPacket(
|
||||
type: MessageType.noiseHandshake.rawValue,
|
||||
@@ -360,14 +1294,15 @@ struct BLENoisePacketHandlerTests {
|
||||
|
||||
private func makeEncryptedPacket(
|
||||
recipientID: Data?,
|
||||
timestamp: UInt64 = 900_000
|
||||
timestamp: UInt64 = 900_000,
|
||||
payload: Data = Data([0xC0, 0xFF, 0xEE])
|
||||
) -> BitchatPacket {
|
||||
BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: remotePeerID.id) ?? Data(),
|
||||
recipientID: recipientID,
|
||||
timestamp: timestamp,
|
||||
payload: Data([0xC0, 0xFF, 0xEE]),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: TransportConfig.messageTTLDefault
|
||||
)
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
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))
|
||||
#expect(
|
||||
PeerCapabilities.localSupported.contains(.privateMediaReceipts)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct BLEPrivateMediaReceiptStoreTests {
|
||||
private struct TestError: Error {}
|
||||
|
||||
private let messageID = "media-00112233445566778899aabbccddeeff"
|
||||
|
||||
@Test
|
||||
func acceptedReceiptPersistsAcrossStoreInstances() throws {
|
||||
let root = makeRoot("persist")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let payload = try makePayload(in: root)
|
||||
|
||||
let first = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(first.commitAccepted(messageID: messageID, storedURL: payload))
|
||||
#expect(first.state(for: messageID) == .accepted(payload))
|
||||
|
||||
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(relaunched.state(for: messageID) == .accepted(payload))
|
||||
}
|
||||
|
||||
@Test
|
||||
func directoryEnumerationFailureIsUnavailableAndRetriesWithoutCachingEmpty() throws {
|
||||
let root = makeRoot("list-failure")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let payload = try makePayload(in: root)
|
||||
#expect(BLEPrivateMediaReceiptStore(baseDirectory: root).commitAccepted(
|
||||
messageID: messageID,
|
||||
storedURL: payload
|
||||
))
|
||||
let record = receiptRecord(in: root)
|
||||
#expect(FileManager.default.fileExists(atPath: record.path))
|
||||
|
||||
var shouldFail = true
|
||||
let store = BLEPrivateMediaReceiptStore(
|
||||
baseDirectory: root,
|
||||
directoryReader: { directory in
|
||||
if shouldFail {
|
||||
shouldFail = false
|
||||
throw TestError()
|
||||
}
|
||||
return try FileManager.default.contentsOfDirectory(
|
||||
at: directory,
|
||||
includingPropertiesForKeys: nil
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
#expect(store.state(for: messageID) == .unavailable)
|
||||
#expect(FileManager.default.fileExists(atPath: record.path))
|
||||
#expect(store.state(for: messageID) == .accepted(payload))
|
||||
}
|
||||
|
||||
@Test
|
||||
func recordReadFailureIsUnavailableAndPreservesReceiptForRetry() throws {
|
||||
let root = makeRoot("read-failure")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let payload = try makePayload(in: root)
|
||||
#expect(BLEPrivateMediaReceiptStore(baseDirectory: root).commitAccepted(
|
||||
messageID: messageID,
|
||||
storedURL: payload
|
||||
))
|
||||
let record = receiptRecord(in: root)
|
||||
|
||||
var shouldFail = true
|
||||
let store = BLEPrivateMediaReceiptStore(
|
||||
baseDirectory: root,
|
||||
dataReader: { url in
|
||||
if shouldFail {
|
||||
shouldFail = false
|
||||
throw TestError()
|
||||
}
|
||||
return try Data(contentsOf: url)
|
||||
}
|
||||
)
|
||||
|
||||
#expect(store.state(for: messageID) == .unavailable)
|
||||
#expect(FileManager.default.fileExists(atPath: record.path))
|
||||
#expect(store.state(for: messageID) == .accepted(payload))
|
||||
}
|
||||
|
||||
@Test
|
||||
func decodeFailureIsUnavailableAndDoesNotDeleteOrCachePastRepair() throws {
|
||||
let root = makeRoot("decode-failure")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let payload = try makePayload(in: root)
|
||||
#expect(BLEPrivateMediaReceiptStore(baseDirectory: root).commitAccepted(
|
||||
messageID: messageID,
|
||||
storedURL: payload
|
||||
))
|
||||
let record = receiptRecord(in: root)
|
||||
let durableBytes = try Data(contentsOf: record)
|
||||
let corruptBytes = Data("{not-json".utf8)
|
||||
try corruptBytes.write(to: record, options: .atomic)
|
||||
|
||||
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(store.state(for: messageID) == .unavailable)
|
||||
#expect(!store.commitAccepted(messageID: messageID, storedURL: payload))
|
||||
#expect(FileManager.default.fileExists(atPath: record.path))
|
||||
#expect(try Data(contentsOf: record) == corruptBytes)
|
||||
|
||||
try durableBytes.write(to: record, options: .atomic)
|
||||
#expect(store.state(for: messageID) == .accepted(payload))
|
||||
}
|
||||
|
||||
@Test
|
||||
func unreadableTombstoneNeverBecomesAbsentOrGetsDeleted() throws {
|
||||
let root = makeRoot("tombstone-decode")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let payload = try makePayload(in: root)
|
||||
let seed = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(seed.commitAccepted(messageID: messageID, storedURL: payload))
|
||||
#expect(seed.recordDeleted(messageID: messageID))
|
||||
#expect(!FileManager.default.fileExists(atPath: payload.path))
|
||||
|
||||
let record = receiptRecord(in: root)
|
||||
let durableBytes = try Data(contentsOf: record)
|
||||
try Data([0xFF, 0x00, 0x7B]).write(to: record, options: .atomic)
|
||||
|
||||
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(relaunched.state(for: messageID) == .unavailable)
|
||||
#expect(FileManager.default.fileExists(atPath: record.path))
|
||||
|
||||
try durableBytes.write(to: record, options: .atomic)
|
||||
#expect(relaunched.state(for: messageID) == .tombstoned)
|
||||
}
|
||||
|
||||
@Test
|
||||
func failedTombstonePersistenceDoesNotPoisonVolatileState() throws {
|
||||
let root = makeRoot("failed-tombstone-write")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(store.state(for: messageID) == .absent)
|
||||
|
||||
// Force the atomic record write itself to fail after the store has
|
||||
// successfully loaded its empty index.
|
||||
let record = receiptRecord(in: root)
|
||||
try FileManager.default.createDirectory(
|
||||
at: record,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
#expect(!store.recordDeleted(messageID: messageID))
|
||||
try FileManager.default.removeItem(at: record)
|
||||
|
||||
// The UI must be able to report the deletion failure without a
|
||||
// process-lifetime tombstone silently hiding a later retry.
|
||||
#expect(store.state(for: messageID) == .absent)
|
||||
}
|
||||
|
||||
@Test
|
||||
func unreleasedAggregateLedgerIsIgnoredAndLeftUntouched() throws {
|
||||
let root = makeRoot("no-legacy-migration")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let files = root.appendingPathComponent("files", isDirectory: true)
|
||||
try FileManager.default.createDirectory(
|
||||
at: files,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let legacy = files.appendingPathComponent(
|
||||
".private-media-receipts.json",
|
||||
isDirectory: false
|
||||
)
|
||||
let bytes = Data(
|
||||
#"{"entries":{"media-00112233445566778899aabbccddeeff":{"relativePath":"images/incoming/old.jpg","acceptedAt":0}}}"#
|
||||
.utf8
|
||||
)
|
||||
try bytes.write(to: legacy, options: .atomic)
|
||||
|
||||
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(store.state(for: messageID) == .absent)
|
||||
#expect(try Data(contentsOf: legacy) == bytes)
|
||||
}
|
||||
|
||||
private func makeRoot(_ label: String) -> URL {
|
||||
FileManager.default.temporaryDirectory.appendingPathComponent(
|
||||
"private-media-receipt-\(label)-\(UUID().uuidString)",
|
||||
isDirectory: true
|
||||
)
|
||||
}
|
||||
|
||||
private func makePayload(in root: URL) throws -> URL {
|
||||
let directory = root.appendingPathComponent(
|
||||
"files/images/incoming",
|
||||
isDirectory: true
|
||||
)
|
||||
try FileManager.default.createDirectory(
|
||||
at: directory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let payload = directory.appendingPathComponent("image.jpg")
|
||||
try Data([0xFF, 0xD8, 0xFF, 0xD9]).write(to: payload)
|
||||
return payload
|
||||
}
|
||||
|
||||
private func receiptRecord(in root: URL) -> URL {
|
||||
root
|
||||
.appendingPathComponent(
|
||||
"files/.private-media-receipts",
|
||||
isDirectory: true
|
||||
)
|
||||
.appendingPathComponent(messageID)
|
||||
.appendingPathExtension("json")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,13 @@ peer's Noise session before BLE fragmentation.
|
||||
`0x21`. A valid bit-8 proof selects Noise `0x20`; a valid no-bit proof or a
|
||||
no-proof timeout reaches the explicit legacy-consent path for an unpinned
|
||||
peer. No timeout automatically sends raw bytes.
|
||||
- `PeerCapabilities.privateMediaReceipts` is bit 9. Its exact-session
|
||||
authenticated proof enables bounded sender-side automatic retry; a public
|
||||
announce never does. It does not replace bit 8: encrypted `0x20` media from
|
||||
bit-8-only prior iOS clients keeps the same deterministic stable ID and
|
||||
delivery ACK. Receivers durably commit that ID before UI delivery or ACK, so
|
||||
a lost proof followed by a later bit-9 retry cannot create a second,
|
||||
random-ID bubble.
|
||||
- An unpinned peer with a stable Noise key but without that capability is
|
||||
eligible for one signed, directed
|
||||
`fileTransfer`, matching the pre-migration wire form used by older iOS and
|
||||
@@ -100,16 +107,8 @@ temporary `0x09` alias. Ordinary Noise messages retain their 64 KiB limit.
|
||||
|
||||
Current Android builds cap each reassembly at 256 fragments. Depending on the
|
||||
negotiated BLE packet size and routing overhead, that is roughly 110-120 KiB,
|
||||
well below iOS's absolute inbound ceiling. That cap only applies to those
|
||||
receivers, which take private media exclusively over the directed raw-file
|
||||
migration fallback (they do not implement the encrypted `0x20` path).
|
||||
Private-media v1 therefore runs the actual route-aware BLE fragment planner
|
||||
before a consented legacy send and rejects any plan above 256 fragments with a
|
||||
visible failure. Encrypted sends go only to peers that advertised the
|
||||
`privateMedia` capability — modern clients that reassemble up to the full
|
||||
receiver ceiling (10,000 fragments) — so they are not held to Android's cap and
|
||||
iOS→iOS photos in the ~120-512 KiB range keep working. This fragment-count
|
||||
contract, rather than a guessed byte threshold, stays correct as route overhead
|
||||
changes. A future Android client that adopts `0x20` but still caps its
|
||||
reassembler would need to negotiate an explicit per-peer fragment limit
|
||||
(tracked as a #1434 follow-up).
|
||||
well below iOS's absolute inbound ceiling. Private-media v1 therefore runs the
|
||||
actual route-aware BLE fragment planner before both encrypted and consented
|
||||
legacy sends and rejects any plan above 256 fragments with a visible failure.
|
||||
This fragment-count contract, rather than a guessed byte threshold, stays
|
||||
correct as route overhead changes.
|
||||
|
||||
@@ -28,6 +28,18 @@ 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)
|
||||
/// Stable private-media IDs are durably deduplicated by the receiver and
|
||||
/// correlated delivery/read receipts permit bounded automatic resend.
|
||||
///
|
||||
/// Bit 8 remains the encrypted-media compatibility contract. Bit 9 only
|
||||
/// enables sender-side automatic retry after exact-session proof.
|
||||
public static let privateMediaReceipts =
|
||||
PeerCapabilities(rawValue: 1 << 9)
|
||||
/// 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.
|
||||
|
||||
@@ -18,10 +18,30 @@ struct PeerCapabilitiesTests {
|
||||
#expect(PeerCapabilities.meshDiagnostics.encoded() == Data([0x40]))
|
||||
#expect(PeerCapabilities.privateMedia.encoded() == Data([0x00, 0x01]))
|
||||
|
||||
let high = PeerCapabilities(rawValue: 1 << 9)
|
||||
#expect(high.encoded() == Data([0x00, 0x02]))
|
||||
#expect(
|
||||
PeerCapabilities.privateMediaReceipts.encoded()
|
||||
== Data([0x00, 0x02])
|
||||
)
|
||||
#expect(
|
||||
PeerCapabilities.nonDestructiveNoiseReplacement.encoded()
|
||||
== Data([0x00, 0x04])
|
||||
)
|
||||
|
||||
let all: PeerCapabilities = [.prekeys, .wifiBulk, .gateway, .groups, .board, .vouch, .meshDiagnostics, .privateMedia]
|
||||
let high = PeerCapabilities(rawValue: 1 << 11)
|
||||
#expect(high.encoded() == Data([0x00, 0x08]))
|
||||
|
||||
let all: PeerCapabilities = [
|
||||
.prekeys,
|
||||
.wifiBulk,
|
||||
.gateway,
|
||||
.groups,
|
||||
.board,
|
||||
.vouch,
|
||||
.meshDiagnostics,
|
||||
.privateMedia,
|
||||
.privateMediaReceipts,
|
||||
.nonDestructiveNoiseReplacement
|
||||
]
|
||||
#expect(PeerCapabilities(encoded: all.encoded()) == all)
|
||||
#expect(PeerCapabilities(encoded: high.encoded()) == high)
|
||||
#expect(PeerCapabilities(encoded: PeerCapabilities([]).encoded()) == [])
|
||||
|
||||
Vendored
+405
-431
@@ -1,442 +1,416 @@
|
||||
Relay URL,Latitude,Longitude
|
||||
bitchat.nostr1.com,40.7057,-74.0136
|
||||
relay.fundstr.me,42.3601,-71.0589
|
||||
nostr.2b9t.xyz,34.0549,-118.243
|
||||
armada.sharegap.net,43.6532,-79.3832
|
||||
nostr.chaima.info,51.5072,-0.127586
|
||||
nosflare-leefcore.leefcore.workers.dev,43.6532,-79.3832
|
||||
ribo.eu.nostria.app:443,43.6532,-79.3832
|
||||
relay.lightning.pub,39.0438,-77.4874
|
||||
relay.nostu.be,40.4167,-3.70329
|
||||
nostr.whitenode45.ddns.net,40.55,-74.4758
|
||||
nostr.carroarmato0.be:443,50.914,3.21378
|
||||
cdn.satellite.earth,40.8302,-74.1299
|
||||
relay2.veganostr.com,60.1699,24.9384
|
||||
relay.layer.systems:443,49.0291,8.35695
|
||||
relay0.gfcom.info,13.7653,100.647
|
||||
relay.mmwaves.de:443,48.8575,2.35138
|
||||
offchain.pub,39.1585,-94.5728
|
||||
bcast.girino.org,43.6532,-79.3832
|
||||
staging.yabu.me,35.6092,139.73
|
||||
nostr.overpay.com,29.7449,-95.5343
|
||||
bridge.tagomago.me,42.3601,-71.0589
|
||||
nostr-01.yakihonne.com,1.32123,103.695
|
||||
strfry.bonsai.com,39.0438,-77.4874
|
||||
relay.sharegap.net,43.6532,-79.3832
|
||||
nostr.islandarea.net,35.4669,-97.6473
|
||||
dm-test-strfry-generic.samt.st,43.6532,-79.3832
|
||||
treuzkas.branruz.com,48.8575,2.35138
|
||||
relay-rpi.edufeed.org:443,49.4521,11.0767
|
||||
vault.iris.to:443,43.6532,-79.3832
|
||||
node.kommonzenze.de,49.4521,11.0767
|
||||
nostr.thalheim.io:443,60.1699,24.9384
|
||||
soloco.nl,43.6532,-79.3832
|
||||
strfry.shock.network,39.0438,-77.4874
|
||||
nostr-relay.zimage.com,34.0549,-118.243
|
||||
public.crostr.com:443,43.6532,-79.3832
|
||||
nostr.sathoarder.com:443,48.5734,7.75211
|
||||
relay.angor.io,48.1046,11.6002
|
||||
relay.wellorder.net,45.5201,-122.99
|
||||
relay.mwaters.net,50.9871,2.12554
|
||||
relay.staging.commonshub.brussels,49.4543,11.0746
|
||||
nostr-verified.wellorder.net,45.5201,-122.99
|
||||
nostr-pub.wellorder.net,45.5201,-122.99
|
||||
relay.lab.rytswd.com,49.4543,11.0746
|
||||
relay.paulstephenborile.com:443,49.4543,11.0746
|
||||
relay.binaryrobot.com,43.6532,-79.3832
|
||||
nostr-2.21crypto.ch,47.5356,8.73209
|
||||
relay.kaleidoswap.com,50.8476,4.35717
|
||||
relay.libernet.app:443,43.6532,-79.3832
|
||||
relay.homeinhk.xyz,35.694,139.754
|
||||
relay.manneken.brussels,49.4543,11.0746
|
||||
nostr.spicyz.io:443,43.6532,-79.3832
|
||||
relay.lanacoin-eternity.com:443,40.8302,-74.1299
|
||||
ribo.us.nostria.app:443,43.6532,-79.3832
|
||||
relay.loveisbitcoin.com,43.6532,-79.3832
|
||||
relay.angor.io:443,48.1046,11.6002
|
||||
relay02.lnfi.network,35.6764,139.65
|
||||
relay.cosmicbolt.net:443,37.3986,-121.964
|
||||
spookstr2.nostr1.com:443,40.7057,-74.0136
|
||||
fanfares.nostr1.com:443,40.7057,-74.0136
|
||||
x.kojira.io,43.6532,-79.3832
|
||||
freelay.sovbit.host,60.1699,24.9384
|
||||
nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397
|
||||
nrs-01.darkcloudarcade.com,39.0997,-94.5786
|
||||
relay.endfiat.money:443,59.3327,18.0656
|
||||
relay.paulstephenborile.com,49.4543,11.0746
|
||||
rele.speyhard.fi,51.5072,-0.127586
|
||||
relay.froth.zone,60.1699,24.9384
|
||||
relay.nostr.blockhenge.com,39.0438,-77.4874
|
||||
nrl.ceskar.xyz,50.5145,16.0119
|
||||
rilo.nostria.app,43.6532,-79.3832
|
||||
nostr.overmind.lol:443,43.6532,-79.3832
|
||||
nostr.snowbla.de:443,50.4754,12.3683
|
||||
nostrrelay.taylorperron.com,45.5029,-73.5723
|
||||
chorus.pjv.me,45.5201,-122.99
|
||||
relay.nostr.place,43.6532,-79.3832
|
||||
bucket.coracle.social,37.7775,-122.397
|
||||
nostr.girino.org:443,43.6532,-79.3832
|
||||
relay.aarpia.com,37.3986,-121.964
|
||||
nostr.thalheim.io,60.1699,24.9384
|
||||
ec2.f7z.io,60.1699,24.9384
|
||||
relay.trotters.cc,43.6532,-79.3832
|
||||
relay.mccormick.cx:443,52.3563,4.95714
|
||||
relay.momostr.pink,43.6532,-79.3832
|
||||
relay.nostr.net,43.6532,-79.3832
|
||||
conduitl2.fly.dev,37.7648,-122.432
|
||||
chat-relay.zap-work.com,43.6532,-79.3832
|
||||
relay.ditto.pub,43.6532,-79.3832
|
||||
relay.veganostr.com,60.1699,24.9384
|
||||
relay.minibolt.info:443,43.6532,-79.3832
|
||||
adre.su,59.9311,30.3609
|
||||
bitcoinostr.duckdns.org,41.1976,1.11167
|
||||
nostr.computingcache.com:443,34.0356,-118.442
|
||||
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
|
||||
nostr.hekster.org:443,37.3986,-121.964
|
||||
nostr.88mph.life,52.1941,-2.21905
|
||||
wot.dergigi.com,64.1476,-21.9392
|
||||
nostr.planix.org,43.6532,-79.3832
|
||||
relay.satsmarkt.club,52.6907,4.8181
|
||||
nostrcity-club.fly.dev:443,37.7648,-122.432
|
||||
aeon.libretechsystems.xyz,55.486,9.86577
|
||||
testnet.samt.st,43.6532,-79.3832
|
||||
nostr.data.haus,50.4754,12.3683
|
||||
wot.sudocarlos.com,43.6532,-79.3832
|
||||
relay-fra.zombi.cloudrodion.com:443,48.8566,2.35222
|
||||
shu01.shugur.net,21.4902,39.2246
|
||||
relay.gulugulu.moe:443,43.6532,-79.3832
|
||||
relay2.angor.io:443,48.1046,11.6002
|
||||
relay.libernet.app,43.6532,-79.3832
|
||||
directories-safe-motherboard-recipients.trycloudflare.com,43.6532,-79.3832
|
||||
wot.nostr.party,36.1659,-86.7844
|
||||
relay.angor.io,48.1046,11.6002
|
||||
relay-arg.zombi.cloudrodion.com,1.35208,103.82
|
||||
nostr-01.yakihonne.com,1.32123,103.695
|
||||
nostr-relay.cbrx.io,43.6532,-79.3832
|
||||
relay.guggero.org,46.5971,9.59652
|
||||
nostr.snowbla.de,60.1699,24.9384
|
||||
relay.zone667.com,60.1699,24.9384
|
||||
nostr.wild-vibes.ts.net,48.8566,2.35222
|
||||
relay.nostr.com,50.1109,8.68213
|
||||
nostr.iskarion.ddns.net,43.3076,-2.95421
|
||||
relay-dev.satlantis.io,39.0438,-77.4874
|
||||
relay.sovereignresonance.org,48.9006,2.25929
|
||||
relay.nostrian-conquest.com,41.223,-111.974
|
||||
relay.aidatanorge.no,43.6532,-79.3832
|
||||
strfry.apps3.slidestr.net,40.4167,-3.70329
|
||||
relay.klabo.world,47.2343,-119.853
|
||||
nostr.data.haus:443,50.4754,12.3683
|
||||
testr.nymble.world,40.8054,-74.0241
|
||||
relay.inforsupports.com,43.6532,-79.3832
|
||||
nexus.libernet.app:443,43.6532,-79.3832
|
||||
relay.islandbitcoin.com,12.8498,77.6545
|
||||
relay-testnet.k8s.layer3.news,37.3387,-121.885
|
||||
nostr-relay.xbytez.io,50.6924,3.20113
|
||||
kasztanowa.bieda.it,43.6532,-79.3832
|
||||
nostrcity-club.fly.dev,37.7648,-122.432
|
||||
relay.typedcypher.com,51.5072,-0.127586
|
||||
nostr.na.social:443,43.6532,-79.3832
|
||||
relay.laantungir.net,-19.4692,-42.5315
|
||||
relay-dev.satlantis.io:443,40.8302,-74.1299
|
||||
rilo.nostria.app,43.6532,-79.3832
|
||||
nostr.hekster.org:443,37.3986,-121.964
|
||||
nostr-relay.amethyst.name:443,39.0067,-77.4291
|
||||
chat-relay.zap-work.com:443,43.6532,-79.3832
|
||||
relay.edufeed.org,49.4521,11.0767
|
||||
syb.lol:443,43.6532,-79.3832
|
||||
relay.sigit.io,50.4754,12.3683
|
||||
nostr-relay.xbytez.io:443,50.6924,3.20113
|
||||
relay.wavefunc.live,41.8781,-87.6298
|
||||
nostr.sathoarder.com,48.5734,7.75211
|
||||
myvoiceourstory.org,37.3598,-121.981
|
||||
relay.underorion.se,50.1109,8.68213
|
||||
nostr.data.haus,50.4754,12.3683
|
||||
relay.erybody.com,41.4513,-81.7021
|
||||
espelho.girino.org,43.6532,-79.3832
|
||||
nostr.pbfs.io:443,50.4754,12.3683
|
||||
wot.dergigi.com,64.1476,-21.9392
|
||||
nostr.bitcoiner.social:443,47.6743,-117.112
|
||||
dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832
|
||||
relay.gulugulu.moe,43.6532,-79.3832
|
||||
nostr.spicyz.io,43.6532,-79.3832
|
||||
relay.cypherflow.ai,48.8575,2.35138
|
||||
treuzkas.branruz.com,48.8575,2.35138
|
||||
relay1.nostrchat.io,60.1699,24.9384
|
||||
kotukonostr.onrender.com,37.7775,-122.397
|
||||
nostr.plantroon.com,50.1013,8.62643
|
||||
nostr.davenov.com,50.1109,8.68213
|
||||
node.kommonzenze.de,49.4521,11.0767
|
||||
relay2.veganostr.com,60.1699,24.9384
|
||||
armada.sharegap.net,43.6532,-79.3832
|
||||
wot.makenomistakes.ca,43.7064,-79.3986
|
||||
nostr.2b9t.xyz:443,34.0549,-118.243
|
||||
relay.libernet.app:443,43.6532,-79.3832
|
||||
relay.dreamith.to:443,43.6532,-79.3832
|
||||
relay.lightning.pub:443,39.0438,-77.4874
|
||||
nostr.rtvslawenia.com,49.4543,11.0746
|
||||
nostr.21crypto.ch,47.5356,8.73209
|
||||
relay.ditto.pub:443,43.6532,-79.3832
|
||||
relay.plebchain.club,43.6532,-79.3832
|
||||
memlay.v0l.io,53.3498,-6.26031
|
||||
nostr.chaima.info:443,50.1109,8.68213
|
||||
relay.wavlake.com:443,41.2619,-95.8608
|
||||
nostr.thalheim.io:443,60.1699,24.9384
|
||||
relay.lightning.pub,39.0438,-77.4874
|
||||
dev.relay.edufeed.org:443,49.4521,11.0767
|
||||
nostr.myshosholoza.co.za:443,52.3913,4.66545
|
||||
relay.binaryrobot.com:443,43.6532,-79.3832
|
||||
wot.nostr.place,43.6532,-79.3832
|
||||
nostr.sathoarder.com:443,48.5734,7.75211
|
||||
thecitadel.nostr1.com,40.7057,-74.0136
|
||||
relay.artx.market,43.6548,-79.3885
|
||||
nos.lol,50.4754,12.3683
|
||||
nostr.plantroon.com:443,50.1013,8.62643
|
||||
premium.primal.net,43.6532,-79.3832
|
||||
nas01xanthosnet.synology.me:7778,47.1285,8.74735
|
||||
nostrja-kari.heguro.com,43.6532,-79.3832
|
||||
relay.mrmave.work,43.6532,-79.3832
|
||||
nostrelay.circum.space,52.6907,4.8181
|
||||
mostro-p2p.tech,50.1109,8.68213
|
||||
wot.shaving.kiwi,43.6532,-79.3832
|
||||
relay.fundstr.me,42.3601,-71.0589
|
||||
nostrelay.circum.space:443,52.6907,4.8181
|
||||
relay.nostrdice.com,-33.8688,151.209
|
||||
relay.getvia.xyz,60.1699,24.9384
|
||||
strfry.shock.network:443,39.0438,-77.4874
|
||||
relay.nostrmap.net:443,60.1699,24.9384
|
||||
nostr.stakey.net:443,52.3676,4.90414
|
||||
relay.nearhood.co.uk,51.5072,-0.127586
|
||||
no.str.cr,10.6352,-85.4378
|
||||
relay.getsafebox.app:443,43.6532,-79.3832
|
||||
relay0.gfcom.info,13.6992,100.694
|
||||
nostr.ps1829.com,33.8851,130.883
|
||||
relay2.angor.io,48.1046,11.6002
|
||||
relay.stickeroo.is-cool.dev,37.3387,-121.885
|
||||
ricardo-oem.tailb5546.ts.net,40.7128,-74.006
|
||||
relay.typedcypher.com:443,51.5072,-0.127586
|
||||
relay.paulstephenborile.com,49.4543,11.0746
|
||||
nittom.nostr1.com,40.7057,-74.0136
|
||||
conduitl2.fly.dev,37.7648,-122.432
|
||||
nostr.rikmeijer.nl,51.7111,5.36809
|
||||
relay.thecryptosquid.com,50.4754,12.3683
|
||||
spookstr2.nostr1.com,40.7057,-74.0136
|
||||
offchain.bostr.online,43.6532,-79.3832
|
||||
nostr.planix.org,43.6532,-79.3832
|
||||
relay.mccormick.cx,52.3563,4.95714
|
||||
0x-nostr-relay.fly.dev,37.7648,-122.432
|
||||
nostr.wecsats.io,43.6532,-79.3832
|
||||
schnorr.me,43.6532,-79.3832
|
||||
relay.satmaxt.xyz,43.6532,-79.3832
|
||||
relay.bornheimer.app,51.5072,-0.127586
|
||||
relay.nostrhub.fr,48.1045,11.6004
|
||||
blossom.gnostr.cloud:443,43.6532,-79.3832
|
||||
nostr-02.yakihonne.com:443,1.32123,103.695
|
||||
dev.relay.stream,43.6532,-79.3832
|
||||
ithurtswhenip.ee,51.5072,-0.127586
|
||||
nostr.myshosholoza.co.za,52.3913,4.66545
|
||||
relayrs.notoshi.win:443,43.6532,-79.3832
|
||||
relay-rpi.edufeed.org:443,49.4521,11.0767
|
||||
relay.olas.app:443,60.1699,24.9384
|
||||
nostr.unkn0wn.world,46.8499,9.53287
|
||||
relay.mitchelltribe.com,39.0438,-77.4874
|
||||
yabu.me,35.6092,139.73
|
||||
nostr.nodesmap.com,59.3327,18.0656
|
||||
dm-test-strfry-generic.samt.st,43.6532,-79.3832
|
||||
nostr2.girino.org:443,43.6532,-79.3832
|
||||
wot.brightbolt.net,47.6735,-116.781
|
||||
strfry.shock.network,39.0438,-77.4874
|
||||
relay.kilombino.com,43.6532,-79.3832
|
||||
relay.nostr.blockhenge.com,39.0438,-77.4874
|
||||
shu04.shugur.net,25.2048,55.2708
|
||||
relay-rpi.edufeed.org,49.4521,11.0767
|
||||
relay.bullishbounty.com:443,43.6532,-79.3832
|
||||
vault.iris.to:443,43.6532,-79.3832
|
||||
relay.mostro.network:443,40.8302,-74.1299
|
||||
offchain.pub:443,39.1585,-94.5728
|
||||
soloco.nl,43.6532,-79.3832
|
||||
relay.nostu.be,40.4167,-3.70329
|
||||
nostr.pbfs.io,50.4754,12.3683
|
||||
relay.directsponsor.net,42.8864,-78.8784
|
||||
relay.decentralia.fr,49.4282,10.9796
|
||||
relayrs.notoshi.win,43.6532,-79.3832
|
||||
nostr-relay.amethyst.name,39.0067,-77.4291
|
||||
relay.arx-ccn.com,50.4754,12.3683
|
||||
nostr.spaceshell.xyz,43.6532,-79.3832
|
||||
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
|
||||
rilo.nostria.app:443,43.6532,-79.3832
|
||||
relay.trotters.cc:443,43.6532,-79.3832
|
||||
nostr.overmind.lol:443,43.6532,-79.3832
|
||||
nostr.girino.org:443,43.6532,-79.3832
|
||||
bitsat.molonlabe.holdings,51.4012,-1.3147
|
||||
nostr.azzamo.net,52.2633,21.0283
|
||||
insta-relay.apps3.slidestr.net,40.4167,-3.70329
|
||||
bridge.tagomago.me,42.3601,-71.0589
|
||||
nostr.thalheim.io,60.1699,24.9384
|
||||
relay.artx.market:443,43.6548,-79.3885
|
||||
nostr.openhoofd.nl,51.5717,3.70417
|
||||
nostr.bond,50.1109,8.68213
|
||||
relay.earthly.city,34.1749,-118.54
|
||||
nexus.libernet.app,43.6532,-79.3832
|
||||
relay.plebeian.market,50.1109,8.68213
|
||||
relay.nostr.net,43.6532,-79.3832
|
||||
nostr.overmind.lol,43.6532,-79.3832
|
||||
relay.ohstr.com,43.6532,-79.3832
|
||||
testnet-relay.samt.st:443,40.8302,-74.1299
|
||||
relay01.lnfi.network,35.6764,139.65
|
||||
relay.mostr.pub:443,43.6532,-79.3832
|
||||
wot.nostr.party,36.1659,-86.7844
|
||||
relayone.soundhsa.com,39.1008,-94.5811
|
||||
relay.mostro.network,40.8302,-74.1299
|
||||
ribo.eu.nostria.app,43.6532,-79.3832
|
||||
chat-relay.zap-work.com,43.6532,-79.3832
|
||||
relay.nostreon.com,60.1699,24.9384
|
||||
nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874
|
||||
nostr.quali.chat:443,60.1699,24.9384
|
||||
relay.internationalright-wing.org:443,-22.5022,-48.7114
|
||||
relay.mitchelltribe.com:443,39.0438,-77.4874
|
||||
relay.satlantis.io,40.8054,-74.0241
|
||||
nittom.nostr1.com:443,40.7057,-74.0136
|
||||
nostr.janx.com,43.6532,-79.3832
|
||||
nostr.carroarmato0.be:443,50.914,3.21378
|
||||
relay.mmwaves.de:443,48.8575,2.35138
|
||||
relay.chorus.community:443,48.5333,10.7
|
||||
wot.utxo.one,43.6532,-79.3832
|
||||
relay.plebeian.market:443,50.1109,8.68213
|
||||
relay.cosmicbolt.net,37.3986,-121.964
|
||||
x.kojira.io:443,43.6532,-79.3832
|
||||
top.testrelay.top,43.6532,-79.3832
|
||||
nos.lol:443,50.4754,12.3683
|
||||
dev.relay.edufeed.org,49.4521,11.0767
|
||||
relayone.geektank.ai:443,39.1008,-94.5811
|
||||
relay.nostar.org,43.6532,-79.3832
|
||||
nostr.oxtr.dev:443,50.4754,12.3683
|
||||
nostr.88mph.life,52.1941,-2.21905
|
||||
relay.staging.commonshub.brussels,49.4543,11.0746
|
||||
weboftrust.libretechsystems.xyz,55.4724,9.87335
|
||||
relay.openfarmtools.org,60.1699,24.9384
|
||||
cs-relay.nostrdev.com,50.4754,12.3683
|
||||
relay.inforsupports.com,43.6532,-79.3832
|
||||
nostr-verified.wellorder.net,45.5201,-122.99
|
||||
nostr.hekster.org,37.3986,-121.964
|
||||
relay.gulugulu.moe:443,43.6532,-79.3832
|
||||
relay.mwaters.net,50.9871,2.12554
|
||||
nostrcity-club.fly.dev:443,37.7648,-122.432
|
||||
relay.vrtmrz.net:443,43.6532,-79.3832
|
||||
relay.nostr.place,43.6532,-79.3832
|
||||
relay.wavefunc.live:443,41.8781,-87.6298
|
||||
nostr.islandarea.net,35.4669,-97.6473
|
||||
purplerelay.com:443,43.6532,-79.3832
|
||||
nostr-relay.psfoundation.info:443,39.0438,-77.4874
|
||||
r.0kb.io,32.789,-96.7989
|
||||
relay-us.zombi.cloudrodion.com,40.7862,-74.0743
|
||||
relay.mulatta.io,37.5665,126.978
|
||||
strfry.bonsai.com:443,39.0438,-77.4874
|
||||
bendernostur.duckdns.org:8443,50.1109,8.68213
|
||||
vault.iris.to,43.6532,-79.3832
|
||||
ec2.f7z.io,60.1699,24.9384
|
||||
nostr.debate.report,50.1109,8.68213
|
||||
wot.codingarena.top,50.4754,12.3683
|
||||
relay.layer.systems:443,49.0291,8.35695
|
||||
relay.degmods.com,50.4754,12.3683
|
||||
nostr.mom,50.4754,12.3683
|
||||
ribo.us.nostria.app:443,43.6532,-79.3832
|
||||
adre.su,59.9311,30.3609
|
||||
wot.sudocarlos.com,43.6532,-79.3832
|
||||
relay.nostrian-conquest.com,41.223,-111.974
|
||||
nostr-relay.nextblockvending.com,47.2343,-119.853
|
||||
relay.endfiat.money:443,59.3327,18.0656
|
||||
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
|
||||
nostr.carroarmato0.be,50.914,3.21378
|
||||
relay.cypherflow.ai:443,48.8575,2.35138
|
||||
nostr.girino.org,43.6532,-79.3832
|
||||
nostr.thebiglake.org,32.71,-96.6745
|
||||
strfry.ymir.cloud,43.6532,-79.3832
|
||||
relay.mypathtofire.de,42.8864,-78.8784
|
||||
relay.lanacoin-eternity.com,40.8302,-74.1299
|
||||
nostr.snowbla.de:443,60.1699,24.9384
|
||||
relay.ditto.pub,43.6532,-79.3832
|
||||
relay.damus.io,43.6532,-79.3832
|
||||
relay.ru.ac.th,13.7607,100.627
|
||||
nrs-01.darkcloudarcade.com,39.1008,-94.5811
|
||||
testnet-relay.samt.st,40.8302,-74.1299
|
||||
antiprimal.net,43.6532,-79.3832
|
||||
bitchat.nostr1.com,40.7057,-74.0136
|
||||
relay.snort.social,53.3498,-6.26031
|
||||
relay.mccormick.cx:443,52.3563,4.95714
|
||||
relay02.lnfi.network,35.6764,139.65
|
||||
srtrelay.c-stellar.net,43.6532,-79.3832
|
||||
relay.minibolt.info,43.6532,-79.3832
|
||||
nostrride.io,37.3986,-121.964
|
||||
articles.layer3.news:443,37.3387,-121.885
|
||||
rele.speyhard.fi,51.5072,-0.127586
|
||||
relay.aarpia.com,37.3986,-121.964
|
||||
nostr.chaima.info,50.1109,8.68213
|
||||
relay.wisp.talk:443,49.4543,11.0746
|
||||
relay.agorist.space:443,52.3734,4.89406
|
||||
strfry.bonsai.com,39.0438,-77.4874
|
||||
nostr.hifish.org,47.4244,8.57658
|
||||
offchain.pub,39.1585,-94.5728
|
||||
nostr.spicyz.io:443,43.6532,-79.3832
|
||||
relay.beginningend.com,35.2227,-97.4786
|
||||
relay.sharegap.net,43.6532,-79.3832
|
||||
nostr.purpura.cloud,43.6532,-79.3832
|
||||
nrs-01.darkcloudarcade.com:443,39.1008,-94.5811
|
||||
relay.fountain.fm:443,43.6532,-79.3832
|
||||
relay.olas.app,60.1699,24.9384
|
||||
relay.mmwaves.de,48.8575,2.35138
|
||||
relay.openresist.com:443,43.6532,-79.3832
|
||||
relay.homeinhk.xyz,35.694,139.754
|
||||
relay.libernet.app,43.6532,-79.3832
|
||||
relay.comcomponent.com,43.6532,-79.3832
|
||||
nostr.tac.lol,47.4748,-122.273
|
||||
relay.goodmorningbitcoin.com,43.6532,-79.3832
|
||||
relay.nostriot.com:443,41.5695,-83.9786
|
||||
bcast.girino.org,43.6532,-79.3832
|
||||
nostr.azzamo.net:443,52.2633,21.0283
|
||||
relay.islandbitcoin.com:443,12.8498,77.6545
|
||||
pool.libernet.app,43.6532,-79.3832
|
||||
test.thedude.cloud,50.1109,8.68213
|
||||
nostrelites.org,41.8781,-87.6298
|
||||
nostr.infero.net,35.6764,139.65
|
||||
relay.primal.net,43.6532,-79.3832
|
||||
ribo.nostria.app,43.6532,-79.3832
|
||||
relay.chorus.community,48.5333,10.7
|
||||
bitcoiner.social:443,47.6743,-117.112
|
||||
relay.wisp.talk,49.4543,11.0746
|
||||
relay.layer.systems,49.0291,8.35695
|
||||
relay-dev.satlantis.io,40.8302,-74.1299
|
||||
nostr.bitcoiner.social,47.6743,-117.112
|
||||
relay.lanavault.space:443,60.1699,24.9384
|
||||
relay.staging.plebeian.market,51.5072,-0.127586
|
||||
infinity-signal-relay.digitalforlifeagency.workers.dev,43.6532,-79.3832
|
||||
relay.fountain.fm,43.6532,-79.3832
|
||||
nostr.middling.mydns.jp,35.8099,140.12
|
||||
relay.dreamith.to,43.6532,-79.3832
|
||||
relay.satmaxt.xyz:443,43.6532,-79.3832
|
||||
shu03.shugur.net,25.2048,55.2708
|
||||
zealand-charts-craig-thru.trycloudflare.com,43.6532,-79.3832
|
||||
nostr.computingcache.com,34.0356,-118.442
|
||||
ribo.us.nostria.app,43.6532,-79.3832
|
||||
relay.agentry.com,42.8864,-78.8784
|
||||
nostr.hifish.org:443,47.4244,8.57658
|
||||
nostr.vulpem.com,49.4543,11.0746
|
||||
relay.cosmicbolt.net:443,37.3986,-121.964
|
||||
nostr-02.yakihonne.com,1.32123,103.695
|
||||
r.0kb.io:443,32.789,-96.7989
|
||||
nostr-relay.corb.net,38.8353,-104.822
|
||||
ribo.eu.nostria.app:443,43.6532,-79.3832
|
||||
nostr-relay.psfoundation.info,39.0438,-77.4874
|
||||
relay.wellorder.net,45.5201,-122.99
|
||||
relay.novospes.com,43.6532,-79.3832
|
||||
nostr-dev.wellorder.net,45.5201,-122.99
|
||||
relay.endfiat.money,59.3327,18.0656
|
||||
relay.angor.io:443,48.1046,11.6002
|
||||
relay-fra.zombi.cloudrodion.com:443,48.8566,2.35222
|
||||
strfry.openhoofd.nl,51.5717,3.70417
|
||||
relay.getsafebox.app,43.6532,-79.3832
|
||||
relay.openresist.com,43.6532,-79.3832
|
||||
relay5.bitransfer.org,43.6532,-79.3832
|
||||
nostr.na.social,43.6532,-79.3832
|
||||
portal-relay.pareto.space,49.0291,8.35696
|
||||
nostr.notribe.net:443,40.8302,-74.1299
|
||||
relay.bitmacro.cloud,43.6532,-79.3832
|
||||
no.str.cr:443,10.6352,-85.4378
|
||||
relay.klabo.world,47.2343,-119.853
|
||||
nostr.notribe.net,40.8302,-74.1299
|
||||
relay.staging.plebeian.market:443,51.5072,-0.127586
|
||||
relay.nostrmap.net,60.1699,24.9384
|
||||
temp.iris.to,43.6532,-79.3832
|
||||
nostr.sovereignservices.xyz,43.6532,-79.3832
|
||||
nostr.liberty.fans,36.9104,-89.5875
|
||||
relay.nostrian-conquest.com:443,41.223,-111.974
|
||||
relay.nostriot.com,41.5695,-83.9786
|
||||
nostrbtc.com,43.6532,-79.3832
|
||||
shu02.shugur.net,21.4902,39.2246
|
||||
relay.kalcafe.xyz,37.3986,-121.964
|
||||
relay.illuminodes.com,43.6532,-79.3832
|
||||
relay.wavlake.com,41.2619,-95.8608
|
||||
nostr.ps1829.com:443,33.8851,130.883
|
||||
dm-test-strfry-discovery.samt.st,43.6532,-79.3832
|
||||
nostr.wecsats.io:443,43.6532,-79.3832
|
||||
nostr-pub.wellorder.net,45.5201,-122.99
|
||||
nostr.dlcdevkit.com:443,40.0992,-83.1141
|
||||
nostr.mom:443,50.4754,12.3683
|
||||
ribo.nostria.app:443,43.6532,-79.3832
|
||||
nostr.2b9t.xyz,34.0549,-118.243
|
||||
nostr.data.haus:443,50.4754,12.3683
|
||||
staging.yabu.me,35.6092,139.73
|
||||
relay.sigit.io:443,50.4754,12.3683
|
||||
relay.edufeed.org:443,49.4521,11.0767
|
||||
nostr-01.yakihonne.com:443,1.32123,103.695
|
||||
reraw.pbla2fish.cc,43.6532,-79.3832
|
||||
cs-relay.nostrdev.com:443,50.4754,12.3683
|
||||
herbstmeister.com,34.0549,-118.243
|
||||
relay.minibolt.info:443,43.6532,-79.3832
|
||||
relay2.angor.io:443,48.1046,11.6002
|
||||
social.amanah.eblessing.co,48.1046,11.6002
|
||||
nostr.stakey.net,52.3676,4.90414
|
||||
nostr.computingcache.com:443,34.0356,-118.442
|
||||
slick.mjex.me,39.0418,-77.4744
|
||||
fanfares.nostr1.com,40.7057,-74.0136
|
||||
bitcoinostr.duckdns.org,43.3434,-3.99532
|
||||
nostr.oxtr.dev,50.4754,12.3683
|
||||
cache.trustr.ing,43.6548,-79.3885
|
||||
purplerelay.com,43.6532,-79.3832
|
||||
nostr-kyomu-haskell.onrender.com,37.7775,-122.397
|
||||
nostr-relay.corb.net:443,38.8353,-104.822
|
||||
relay-dev.gulugulu.moe,43.6532,-79.3832
|
||||
prl.plus,55.7628,37.5983
|
||||
nostr.tac.lol:443,47.4748,-122.273
|
||||
relay.mostr.pub,43.6532,-79.3832
|
||||
schnorr.me:443,43.6532,-79.3832
|
||||
dev-relay.nostreon.com,60.1699,24.9384
|
||||
nostr.islandarea.net:443,35.4669,-97.6473
|
||||
nostr.rtvslawenia.com,49.4543,11.0746
|
||||
relay.bowlafterbowl.com,32.9483,-96.7299
|
||||
nostr.quali.chat:443,60.1699,24.9384
|
||||
relay.plebeian.market,50.1109,8.68213
|
||||
relay-rpi.edufeed.org,49.4521,11.0767
|
||||
r.0kb.io,32.789,-96.7989
|
||||
nostr.notribe.net:443,40.8302,-74.1299
|
||||
relay.getsafebox.app:443,43.6532,-79.3832
|
||||
nostr.dlcdevkit.com:443,40.0992,-83.1141
|
||||
nostrelites.org,34.9582,-81.9907
|
||||
nostr.hoppe-relay.it.com,42.8864,-78.8784
|
||||
nostr.thebiglake.org,32.71,-96.6745
|
||||
nostr-kyomu-haskell.onrender.com,37.7775,-122.397
|
||||
relay.nostriot.com,41.5695,-83.9786
|
||||
nostr.christiansass.de,51.7634,7.8887
|
||||
relay.btcforplebs.com,43.6532,-79.3832
|
||||
nostr.tagomago.me,42.3601,-71.0589
|
||||
relayone.geektank.ai,39.0997,-94.5786
|
||||
relay.dreamith.to:443,43.6532,-79.3832
|
||||
nostr.liberty.fans,36.8767,-89.5879
|
||||
wot.makenomistakes.ca,43.7064,-79.3986
|
||||
relay.goodmorningbitcoin.com,43.6532,-79.3832
|
||||
relay.layer.systems,49.0291,8.35695
|
||||
relay.paulstephenborile.com:443,49.4543,11.0746
|
||||
relay.ohstr.com,43.6532,-79.3832
|
||||
nostr-relay.xbytez.io:443,50.6924,3.20113
|
||||
nostr.ac,38.958,-77.3592
|
||||
ribo.us.nostria.app,43.6532,-79.3832
|
||||
nostr.21crypto.ch,47.5356,8.73209
|
||||
relay.chorus.community:443,48.5333,10.7
|
||||
relay.cypherflow.ai,48.8575,2.35138
|
||||
relay.agorist.space:443,52.3734,4.89406
|
||||
relay.nostrian-conquest.com:443,41.223,-111.974
|
||||
relay.keykeeper.world,40.7824,-74.0711
|
||||
relay.getvia.xyz,60.1699,24.9384
|
||||
relay.nuts.cash,52.3676,4.90414
|
||||
kotukonostr.onrender.com,37.7775,-122.397
|
||||
relay.minibolt.info,43.6532,-79.3832
|
||||
relay.dwadziesciajeden.pl,52.2297,21.0122
|
||||
relay.fountain.fm:443,43.6532,-79.3832
|
||||
relay.fountain.fm,43.6532,-79.3832
|
||||
nostr-02.uid.ovh,50.9871,2.12554
|
||||
relay.lanavault.space:443,60.1699,24.9384
|
||||
nostr.carroarmato0.be,50.914,3.21378
|
||||
nexus.libernet.app:443,43.6532,-79.3832
|
||||
relay.artio.inf.unibe.ch,46.9501,7.43678
|
||||
bucket.coracle.social,37.7775,-122.397
|
||||
blossom.gnostr.cloud,43.6532,-79.3832
|
||||
relay.binaryrobot.com,43.6532,-79.3832
|
||||
relay.earthly.city,34.1749,-118.54
|
||||
nostr.hifish.org,47.4244,8.57658
|
||||
offchain.pub:443,39.1585,-94.5728
|
||||
relay.bullishbounty.com:443,43.6532,-79.3832
|
||||
strfry.openhoofd.nl:443,51.5717,3.70417
|
||||
cs-relay.nostrdev.com:443,50.4754,12.3683
|
||||
strfry.ymir.cloud,43.6532,-79.3832
|
||||
nostrbtc.com,43.6532,-79.3832
|
||||
relay.directsponsor.net,42.8864,-78.8784
|
||||
nostr2.girino.org,43.6532,-79.3832
|
||||
relay.sigit.io:443,50.4754,12.3683
|
||||
relay.getsafebox.app,43.6532,-79.3832
|
||||
antiprimal.net,43.6532,-79.3832
|
||||
nostr.sathoarder.com,48.5734,7.75211
|
||||
inbox.scuba323.com,40.8218,-74.45
|
||||
nrs-01.darkcloudarcade.com:443,39.0997,-94.5786
|
||||
nostr.tac.lol,47.4748,-122.273
|
||||
nostr.davenov.com,50.1109,8.68213
|
||||
relay.trotters.cc:443,43.6532,-79.3832
|
||||
nostr.plantroon.com:443,50.1013,8.62643
|
||||
relay.nostreon.com,60.1699,24.9384
|
||||
nostr.easycryptosend.it,43.6532,-79.3832
|
||||
nostr-01.yakihonne.com:443,1.32123,103.695
|
||||
relay-testnet.k8s.layer3.news,37.3387,-121.885
|
||||
nostr.purpura.cloud,43.6532,-79.3832
|
||||
insta-relay.apps3.slidestr.net,40.4167,-3.70329
|
||||
nostr.mifen.me,43.6532,-79.3832
|
||||
testnet-relay.samt.st:443,40.8302,-74.1299
|
||||
nostr.2b9t.xyz:443,34.0549,-118.243
|
||||
relay.wavlake.com:443,41.2619,-95.8608
|
||||
relay.wisp.talk:443,49.4543,11.0746
|
||||
relay-dev.satlantis.io:443,39.0438,-77.4874
|
||||
relay.satlantis.io,39.0438,-77.4874
|
||||
relay.staging.plebeian.market,51.5072,-0.127586
|
||||
relay.openfarmtools.org,60.1699,24.9384
|
||||
relay.nostrhub.fr,48.1045,11.6004
|
||||
nostr-relay.xbytez.io,50.6924,3.20113
|
||||
relay.binaryrobot.com:443,43.6532,-79.3832
|
||||
relay.samt.st,40.8302,-74.1299
|
||||
relay.illuminodes.com,43.6532,-79.3832
|
||||
relay.liberbitworld.org,43.6532,-79.3832
|
||||
relay.olas.app:443,60.1699,24.9384
|
||||
no.str.cr,8.96171,-83.5246
|
||||
dm-test-strfry-discovery.samt.st,43.6532,-79.3832
|
||||
wot.rejecttheframe.xyz,43.6532,-79.3832
|
||||
relay.nostriot.com:443,41.5695,-83.9786
|
||||
nostr.plantroon.com,50.1013,8.62643
|
||||
nostr-01.uid.ovh,50.9871,2.12554
|
||||
relay.openresist.com:443,43.6532,-79.3832
|
||||
nostr.overmind.lol,43.6532,-79.3832
|
||||
relay.internationalright-wing.org,-22.5022,-48.7114
|
||||
nostr.myshosholoza.co.za:443,52.3676,4.90414
|
||||
nostr.pbfs.io:443,50.4754,12.3683
|
||||
21milionidinostr.duckdns.org,41.8967,12.4822
|
||||
nostr.4rs.nl,49.0291,8.35696
|
||||
relay.lanavault.space,60.1699,24.9384
|
||||
relay.mostr.pub,43.6532,-79.3832
|
||||
relay.nostar.org,43.6532,-79.3832
|
||||
nostr.mom,50.4754,12.3683
|
||||
relay.decentralia.fr,48.122,11.589
|
||||
relay.agentry.com,42.8864,-78.8784
|
||||
relay2.angor.io,48.1046,11.6002
|
||||
slick.mjex.me,39.0418,-77.4744
|
||||
relay-us.zombi.cloudrodion.com,40.7862,-74.0743
|
||||
relay.vrtmrz.net:443,43.6532,-79.3832
|
||||
relay.beginningend.com,35.2227,-97.4786
|
||||
chat-relay.zap-work.com:443,43.6532,-79.3832
|
||||
relay.underorion.se,50.1109,8.68213
|
||||
relay.mitchelltribe.com,39.0438,-77.4874
|
||||
relay.qstr.app,51.5072,-0.127586
|
||||
relay.cyberguy.fyi,52.6907,4.8181
|
||||
strfry.bonsai.com:443,39.0438,-77.4874
|
||||
relayone.soundhsa.com:443,39.0997,-94.5786
|
||||
relay.sigit.io,50.4754,12.3683
|
||||
relay.npubhaus.com,43.6532,-79.3832
|
||||
relayrs.notoshi.win,43.6532,-79.3832
|
||||
relay.mitchelltribe.com:443,39.0438,-77.4874
|
||||
relay.44billion.net,43.6532,-79.3832
|
||||
reraw.pbla2fish.cc,43.6532,-79.3832
|
||||
articles.layer3.news:443,37.3387,-121.885
|
||||
nostr.sovereignservices.xyz,43.6532,-79.3832
|
||||
relay.nostx.io,43.6532,-79.3832
|
||||
nostr-relay.amethyst.name,39.0067,-77.4291
|
||||
0x-nostr-relay.fly.dev,37.7648,-122.432
|
||||
relay.ohstr.com:443,43.6532,-79.3832
|
||||
00f2e774.relay.dev.thunderegg.us,39.0438,-77.4874
|
||||
nostr-relay.cbrx.io,43.6532,-79.3832
|
||||
relay.wavlake.com,41.2619,-95.8608
|
||||
purplerelay.com:443,43.6532,-79.3832
|
||||
nostr-pr02.redscrypt.org,52.3676,4.90414
|
||||
fanfares.nostr1.com:443,40.7057,-74.0136
|
||||
kasztanowa.bieda.it,43.6532,-79.3832
|
||||
relay.flashapp.me,43.6548,-79.3885
|
||||
relay.typedcypher.com,51.5072,-0.127586
|
||||
nostr.bond,50.1109,8.68213
|
||||
nostr.azzamo.net,52.2633,21.0283
|
||||
nexus.libernet.app,43.6532,-79.3832
|
||||
relay.cosmicbolt.net,37.3986,-121.964
|
||||
schnorr.me,43.6532,-79.3832
|
||||
relay.mostro.network:443,40.8302,-74.1299
|
||||
relay-arg.zombi.cloudrodion.com,1.35208,103.82
|
||||
relay.chorus.community,48.5333,10.7
|
||||
blossom.gnostr.cloud:443,43.6532,-79.3832
|
||||
syb.lol:443,34.0549,-118.243
|
||||
relay.dyne.org,49.0291,8.35705
|
||||
btc.klendazu.com,41.2861,1.24993
|
||||
wot.nostr.place,43.6532,-79.3832
|
||||
relay.openresist.com,43.6532,-79.3832
|
||||
rilo.nostria.app:443,43.6532,-79.3832
|
||||
no.str.cr:443,8.96171,-83.5246
|
||||
relay.mostr.pub:443,43.6532,-79.3832
|
||||
relay.edufeed.org:443,49.4521,11.0767
|
||||
nostr.debate.report,50.1109,8.68213
|
||||
relay.satmaxt.xyz:443,43.6532,-79.3832
|
||||
relay.artx.market:443,43.6548,-79.3885
|
||||
relay-dev.gulugulu.moe,43.6532,-79.3832
|
||||
relay.novospes.com,43.6532,-79.3832
|
||||
relay.nostr-check.me,43.6532,-79.3832
|
||||
nostr.computingcache.com,34.0356,-118.442
|
||||
nostr.oxtr.dev,50.4754,12.3683
|
||||
relay.fckstate.net,59.3293,18.0686
|
||||
relay.vrtmrz.net,43.6532,-79.3832
|
||||
relay.bornheimer.app,51.5072,-0.127586
|
||||
relay.guggero.org,46.5971,9.59652
|
||||
relay01.lnfi.network,35.6764,139.65
|
||||
wot.shaving.kiwi,43.6532,-79.3832
|
||||
nostr.twinkle.lol,51.902,7.6657
|
||||
relay.edufeed.org,49.4521,11.0767
|
||||
relay.lanacoin-eternity.com,40.8302,-74.1299
|
||||
relay.satmaxt.xyz,43.6532,-79.3832
|
||||
nostr.hifish.org:443,47.4244,8.57658
|
||||
relay.cypherflow.ai:443,48.8575,2.35138
|
||||
infinity-signal-relay.digitalforlifeagency.workers.dev,43.6532,-79.3832
|
||||
nostr.na.social:443,43.6532,-79.3832
|
||||
nostr.rtvslawenia.com:443,49.4543,11.0746
|
||||
relay.mypathtofire.de,42.8864,-78.8784
|
||||
public.crostr.com,43.6532,-79.3832
|
||||
relay.olas.app,60.1699,24.9384
|
||||
relay.agora.social,50.7383,15.0648
|
||||
ribo.nostria.app,43.6532,-79.3832
|
||||
relay.lab.rytswd.com,49.4543,11.0746
|
||||
relay.ditto.pub:443,43.6532,-79.3832
|
||||
porchlight.social,43.6532,-79.3832
|
||||
nostr.notribe.net,40.8302,-74.1299
|
||||
relay.endfiat.money,59.3327,18.0656
|
||||
nostr.myshosholoza.co.za,52.3676,4.90414
|
||||
relay.nearhood.co.uk,51.5134,-0.0890675
|
||||
relay.degmods.com,50.4754,12.3683
|
||||
nostr.novacisko.cz,52.2026,20.9397
|
||||
prl.plus,55.7628,37.5983
|
||||
bruh.samt.st,43.6532,-79.3832
|
||||
strfry.openhoofd.nl,51.5717,3.70417
|
||||
nostr.spicyz.io,43.6532,-79.3832
|
||||
nostr.na.social,43.6532,-79.3832
|
||||
nip85.nosfabrica.com,39.0997,-94.5786
|
||||
premium.primal.net,43.6532,-79.3832
|
||||
fanfares.nostr1.com,40.7057,-74.0136
|
||||
relay.scuba323.com,40.8218,-74.45
|
||||
nostr2.girino.org:443,43.6532,-79.3832
|
||||
relay.mmwaves.de,48.8575,2.35138
|
||||
nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874
|
||||
strfry.shock.network:443,39.0438,-77.4874
|
||||
nostr.snowbla.de,50.4754,12.3683
|
||||
nostr.spaceshell.xyz,43.6532,-79.3832
|
||||
nostr.quali.chat,60.1699,24.9384
|
||||
wot.utxo.one,43.6532,-79.3832
|
||||
relay.mccormick.cx,52.3563,4.95714
|
||||
mostro-p2p.tech,50.1109,8.68213
|
||||
basspistol.org,49.0291,8.35696
|
||||
ribo.nostria.app:443,43.6532,-79.3832
|
||||
chorus.mikedilger.com:444,-36.8906,174.794
|
||||
nostr.oxtr.dev:443,50.4754,12.3683
|
||||
nostr.nodesmap.com,59.3327,18.0656
|
||||
offchain.bostr.online,43.6532,-79.3832
|
||||
purplerelay.com,43.6532,-79.3832
|
||||
relayrs.notoshi.win:443,43.6532,-79.3832
|
||||
relay.wavefunc.live,41.8781,-87.6298
|
||||
relay.dreamith.to,43.6532,-79.3832
|
||||
bendernostur.duckdns.org:8443,50.1109,8.68213
|
||||
relay.nmail.li,50.9871,2.12554
|
||||
nostr-relay.corb.net,39.6478,-104.988
|
||||
relay.staging.plebeian.market:443,51.5072,-0.127586
|
||||
spamspamspamspam.rest,43.6532,-79.3832
|
||||
relay1.gfcom.info,13.9215,100.538
|
||||
schnorr.me:443,43.6532,-79.3832
|
||||
relay.lab.rytswd.com:443,49.4543,11.0746
|
||||
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
|
||||
dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832
|
||||
relay.nostrmap.net,60.1699,24.9384
|
||||
nostr.relay.hedwig.sh,60.1699,24.9384
|
||||
relay.veganostr.com:443,60.1699,24.9384
|
||||
relay.wavefunc.live:443,41.8781,-87.6298
|
||||
nostr.mikoshi.de,52.52,13.405
|
||||
syb.lol,34.0549,-118.243
|
||||
relay1.nostrchat.io,60.1699,24.9384
|
||||
nostr.wecsats.io:443,43.6532,-79.3832
|
||||
nostr.chaima.info:443,51.5072,-0.127586
|
||||
nostr.azzamo.net:443,52.2633,21.0283
|
||||
relay-can.zombi.cloudrodion.com,43.6532,-79.3832
|
||||
nostr.unkn0wn.world,46.8499,9.53287
|
||||
relayone.soundhsa.com,39.0997,-94.5786
|
||||
x.kojira.io,43.6532,-79.3832
|
||||
dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832
|
||||
nostrelay.circum.space,52.6907,4.8181
|
||||
relay.primal.net,43.6532,-79.3832
|
||||
nostr.girino.org,43.6532,-79.3832
|
||||
nostr.pbfs.io,50.4754,12.3683
|
||||
relay.kalcafe.xyz,37.3986,-121.964
|
||||
relay.gulugulu.moe,43.6532,-79.3832
|
||||
top.testrelay.top,43.6532,-79.3832
|
||||
relay.kilombino.com,43.6532,-79.3832
|
||||
nos.lol:443,50.4754,12.3683
|
||||
nos.lol,50.4754,12.3683
|
||||
relay.nostr.place:443,43.6532,-79.3832
|
||||
cache.trustr.ing,43.6548,-79.3885
|
||||
relay.internationalright-wing.org:443,-22.5022,-48.7114
|
||||
relay.laantungir.net,-19.4692,-42.5315
|
||||
relay.lightning.pub:443,39.0438,-77.4874
|
||||
nostr.stakey.net,52.3676,4.90414
|
||||
articles.layer3.news,37.3387,-121.885
|
||||
relay.wisp.talk,49.4543,11.0746
|
||||
relay.pyramid.li,47.4093,8.46503
|
||||
relay.typedcypher.com:443,51.5072,-0.127586
|
||||
dev.relay.stream,43.6532,-79.3832
|
||||
relay.bullishbounty.com,43.6532,-79.3832
|
||||
nostr.mom:443,50.4754,12.3683
|
||||
relay.plebeian.market:443,50.1109,8.68213
|
||||
nostr.hekster.org,37.3986,-121.964
|
||||
nostrcity-club.fly.dev,37.7648,-122.432
|
||||
nostr.vulpem.com,49.4543,11.0746
|
||||
relay-dev.gulugulu.moe:443,43.6532,-79.3832
|
||||
weboftrust.libretechsystems.xyz,55.4724,9.87335
|
||||
nostr-relay.corb.net:443,39.6478,-104.988
|
||||
wheat.happytavern.co,43.6532,-79.3832
|
||||
relay.mappingbitcoin.com,43.6532,-79.3832
|
||||
testnet-relay.samt.st,40.8302,-74.1299
|
||||
relay.bitmacro.cloud,43.6532,-79.3832
|
||||
dev.relay.edufeed.org,49.4521,11.0767
|
||||
myvoiceourstory.org,37.3598,-121.981
|
||||
relay.stickeroo.is-cool.dev,37.3387,-121.885
|
||||
relay.agorist.space,52.3734,4.89406
|
||||
freelay.sovbit.host,60.1699,24.9384
|
||||
nostr-dev.wellorder.net,45.5201,-122.99
|
||||
nostr.middling.mydns.jp,35.8099,140.12
|
||||
cs-relay.nostrdev.com,50.4754,12.3683
|
||||
x.kojira.io:443,43.6532,-79.3832
|
||||
nostrelay.circum.space:443,52.6907,4.8181
|
||||
nostr.janx.com,43.6532,-79.3832
|
||||
relay.mrmave.work,43.6532,-79.3832
|
||||
espelho.girino.org,43.6532,-79.3832
|
||||
hol.is,43.6532,-79.3832
|
||||
ribo.eu.nostria.app,43.6532,-79.3832
|
||||
nostr.yutakobayashi.com,43.6532,-79.3832
|
||||
relay.mostro.network,40.8302,-74.1299
|
||||
communities.nos.social,40.8302,-74.1299
|
||||
relay.solife.me,43.6532,-79.3832
|
||||
yabu.me,35.6092,139.73
|
||||
relay.islandbitcoin.com,12.8498,77.6545
|
||||
nostr.wecsats.io,43.6532,-79.3832
|
||||
nostr.tac.lol:443,47.4748,-122.273
|
||||
relay.arx-ccn.com,50.4754,12.3683
|
||||
nostrride.io,37.3986,-121.964
|
||||
r.0kb.io:443,32.789,-96.7989
|
||||
herbstmeister.com,34.0549,-118.243
|
||||
relay.artx.market,43.6548,-79.3885
|
||||
vault.iris.to,43.6532,-79.3832
|
||||
relay.ru.ac.th,13.7607,100.627
|
||||
temp.iris.to,43.6532,-79.3832
|
||||
social.amanah.eblessing.co,48.1046,11.6002
|
||||
nostr-relay.nextblockvending.com,47.2343,-119.853
|
||||
wot.codingarena.top,50.4754,12.3683
|
||||
relay.sincensura.org,43.6532,-79.3832
|
||||
nostr.quali.chat,60.1699,24.9384
|
||||
relay.vrtmrz.net,43.6532,-79.3832
|
||||
relay-dev.gulugulu.moe:443,43.6532,-79.3832
|
||||
relay.bullishbounty.com,43.6532,-79.3832
|
||||
relay.fckstate.net,59.3293,18.0686
|
||||
nostr.rtvslawenia.com:443,49.4543,11.0746
|
||||
relay.nostx.io,43.6532,-79.3832
|
||||
relay.agorist.space,52.3734,4.89406
|
||||
relay.notoshi.win,13.7829,100.546
|
||||
dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832
|
||||
relay.trotters.cc,43.6532,-79.3832
|
||||
relay.lanavault.space,60.1699,24.9384
|
||||
public.crostr.com:443,43.6532,-79.3832
|
||||
nostr.stakey.net:443,52.3676,4.90414
|
||||
relay.nostr.place:443,43.6532,-79.3832
|
||||
nostr.dlcdevkit.com,40.0992,-83.1141
|
||||
nostr.aruku.ovh,1.27994,103.849
|
||||
satsage.xyz,37.3986,-121.964
|
||||
strfry.apps3.slidestr.net,40.4167,-3.70329
|
||||
nostr2.girino.org,43.6532,-79.3832
|
||||
relay.samt.st,40.8302,-74.1299
|
||||
articles.layer3.news,37.3387,-121.885
|
||||
aeon.libretechsystems.xyz,55.486,9.86577
|
||||
relay.routstr.com,59.4016,17.9455
|
||||
relay.ohstr.com:443,43.6532,-79.3832
|
||||
relay.lanacoin-eternity.com:443,40.8302,-74.1299
|
||||
strfry.openhoofd.nl:443,51.5717,3.70417
|
||||
nostr.blankfors.se,60.1699,24.9384
|
||||
nostr-2.21crypto.ch:443,47.5356,8.73209
|
||||
relayone.soundhsa.com:443,39.1008,-94.5811
|
||||
relay.lab.rytswd.com:443,49.4543,11.0746
|
||||
nostr.tagomago.me,42.3601,-71.0589
|
||||
relay.0xchat.com:443,43.6532,-79.3832
|
||||
|
||||
|
Reference in New Issue
Block a user